1pub mod archetype;
7pub mod boundaries;
8pub mod clustering;
9pub mod cochange;
10pub mod components;
11pub mod flowgraph;
12pub mod flows;
13pub mod impact;
14pub mod invariants;
15pub mod lifecycle;
16pub mod state;
17pub mod trust;
18pub mod workflow;
19
20pub use trust::{TrustedGraphView, TrustPolicy};
21
22impl RealityGraph {
24 pub fn empty() -> RealityGraph {
25 RealityGraph {
26 repo_id: String::new(),
27 entities: HashMap::new(),
28 out: HashMap::new(),
29 inn: HashMap::new(),
30 components: Vec::new(),
31 flows: Vec::new(),
32 invariants: Vec::new(),
33 }
34 }
35}
36
37use scc_core::{Entity, Flow, Invariant, Relationship};
38use scc_store::Store;
39use std::collections::HashMap;
40
41#[derive(Debug, thiserror::Error)]
42pub enum GraphError {
44 #[error("store: {0}")]
45 Store(#[from] scc_store::StoreError),
46 #[error("cochange: {0}")]
47 Cochange(String),
48 #[error("impact: {0}")]
49 Impact(String),
50}
51
52pub type Result<T> = std::result::Result<T, GraphError>;
53
54pub struct RealityGraph {
57 pub repo_id: String,
58 pub entities: HashMap<String, Entity>,
59 pub out: HashMap<String, Vec<Relationship>>,
61 pub inn: HashMap<String, Vec<Relationship>>,
63 pub components: Vec<Entity>,
64 pub flows: Vec<Flow>,
65 pub invariants: Vec<Invariant>,
66}
67
68impl RealityGraph {
69 pub fn load(store: &Store) -> Result<RealityGraph> {
70 let mut entities = HashMap::new();
71 for e in store.all_entities()? {
72 entities.insert(e.id.clone(), e);
73 }
74 let mut out: HashMap<String, Vec<Relationship>> = HashMap::new();
75 let mut inn: HashMap<String, Vec<Relationship>> = HashMap::new();
76 for r in store.all_relationships()? {
77 out.entry(r.subject.clone()).or_default().push(r.clone());
78 inn.entry(r.object.clone()).or_default().push(r);
79 }
80 Ok(RealityGraph {
81 repo_id: store.repo_id.clone(),
82 entities,
83 out,
84 inn,
85 components: store.components()?,
86 flows: store.flows()?,
87 invariants: store.invariants()?,
88 })
89 }
90
91 pub fn entity(&self, id: &str) -> Option<&Entity> {
92 self.entities.get(id)
93 }
94
95 pub fn out_edges(&self, id: &str) -> Vec<&Relationship> {
96 self.out.get(id).map(|v| v.iter().collect()).unwrap_or_default()
97 }
98
99 pub fn in_edges(&self, id: &str) -> Vec<&Relationship> {
100 self.inn.get(id).map(|v| v.iter().collect()).unwrap_or_default()
101 }
102
103 pub fn out_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
104 self.out
105 .get(id)
106 .map(|v| {
107 v.iter()
108 .filter(|r| r.predicate == predicate)
109 .collect()
110 })
111 .unwrap_or_default()
112 }
113
114 pub fn in_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
115 self.inn
116 .get(id)
117 .map(|v| {
118 v.iter()
119 .filter(|r| r.predicate == predicate)
120 .collect()
121 })
122 .unwrap_or_default()
123 }
124
125 pub fn entities_of_kind(&self, kind: &str) -> Vec<&Entity> {
127 let mut v: Vec<&Entity> = self
128 .entities
129 .values()
130 .filter(|e| e.kind == kind)
131 .collect();
132 v.sort_by(|a, b| a.name.cmp(&b.name));
133 v
134 }
135}
136
137pub struct AffectedClosure {
146 pub components: Vec<String>,
148 pub flows: Vec<String>,
150 pub complete: bool,
153}
154
155pub fn affected_closure_with_owners(
157 graph: &RealityGraph,
158 changed_files: &[String],
159 known_owners: &[String],
160) -> AffectedClosure {
161 use std::collections::HashSet;
162 let mut components: HashSet<String> = HashSet::new();
167 for o in known_owners {
168 components.insert(o.clone());
169 }
170 let inner = affected_closure(graph, changed_files);
171 if inner.complete && !components.is_empty() {
172 let mut flows: HashSet<String> = HashSet::new();
174 let sym_comp = symbol_component_map(graph);
175 let comp_set: HashSet<&str> = components.iter().map(|c| c.as_str()).collect();
176 for fl in &graph.flows {
177 for r in graph.out_pred(&fl.id, scc_core::predicates::CONTAINS) {
178 if comp_set.contains(r.object.as_str())
179 || sym_comp.get(&r.object).map(|c| comp_set.contains(c.as_str())).unwrap_or(false)
180 {
181 flows.insert(fl.id.clone());
182 break;
183 }
184 }
185 }
186 let mut components: Vec<String> = components.into_iter().collect();
187 components.sort();
188 let mut flows: Vec<String> = flows.into_iter().collect();
189 flows.sort();
190 return AffectedClosure { components, flows, complete: false };
191 }
192 if !inner.complete {
193 for c in &inner.components {
194 components.insert(c.clone());
195 }
196 }
197 if components.is_empty() {
198 return inner;
199 }
200 let sym_comp = symbol_component_map(graph);
202 let comp_set: HashSet<&str> = components.iter().map(|c| c.as_str()).collect();
203 let mut flows: HashSet<String> = HashSet::new();
204 for fl in &graph.flows {
205 for r in graph.out_pred(&fl.id, scc_core::predicates::CONTAINS) {
206 if comp_set.contains(r.object.as_str())
207 || sym_comp.get(&r.object).map(|c| comp_set.contains(c.as_str())).unwrap_or(false)
208 {
209 flows.insert(fl.id.clone());
210 break;
211 }
212 }
213 }
214 let mut components: Vec<String> = components.into_iter().collect();
215 components.sort();
216 let mut flows: Vec<String> = flows.into_iter().collect();
217 flows.sort();
218 AffectedClosure { components, flows, complete: false }
219}
220
221pub fn affected_closure(graph: &RealityGraph, changed_files: &[String]) -> AffectedClosure {
223 use std::collections::HashSet;
224 let mut components: HashSet<String> = HashSet::new();
232 let mut sym_owner: std::collections::HashMap<String, String> = std::collections::HashMap::new();
238 for c in &graph.components {
239 for r in graph.out_pred(&c.id, scc_core::predicates::CONTAINS) {
240 for sr in graph.out_pred(&r.object, scc_core::predicates::CONTAINS) {
241 sym_owner.insert(sr.object.clone(), c.id.clone());
242 }
243 }
244 }
245 for f in changed_files {
246 let mut found = false;
247 for c in &graph.components {
248 for r in graph.out_pred(&c.id, scc_core::predicates::CONTAINS) {
249 if r.object == *f || r.object.ends_with(&format!("/{f}")) {
252 components.insert(c.id.clone());
253 found = true;
254 break;
255 }
256 }
257 if found {
258 break;
259 }
260 }
261 if !found {
262 for (sym, owner) in &sym_owner {
266 if sym.contains(&format!("/symbol/{f}/")) || sym.ends_with(&format!("/symbol/{f}")) {
267 components.insert(owner.clone());
268 found = true;
269 break;
270 }
271 }
272 }
273 if !found {
274 return AffectedClosure { components: vec![], flows: vec![], complete: true };
276 }
277 }
278 let sym_comp = symbol_component_map(graph);
282 let comp_set: HashSet<&str> = components.iter().map(|c| c.as_str()).collect();
283 let mut flows: HashSet<String> = HashSet::new();
284 for fl in &graph.flows {
285 let mut hit = false;
286 for r in graph.out_pred(&fl.id, scc_core::predicates::CONTAINS) {
287 if comp_set.contains(r.object.as_str()) {
288 hit = true;
289 break;
290 }
291 if let Some(c) = sym_comp.get(&r.object) {
292 if comp_set.contains(c.as_str()) {
293 hit = true;
294 break;
295 }
296 }
297 }
298 if hit {
299 flows.insert(fl.id.clone());
300 }
301 }
302 let mut components: Vec<String> = components.into_iter().collect();
303 components.sort();
304 let mut flows: Vec<String> = flows.into_iter().collect();
305 flows.sort();
306 AffectedClosure { components, flows, complete: false }
307}
308
309pub fn symbol_component_map(graph: &RealityGraph) -> HashMap<String, String> {
310 let mut symbol_comp: HashMap<String, String> = HashMap::new();
311 for c in &graph.components {
312 for r in graph.out_pred(&c.id, scc_core::predicates::CONTAINS) {
313 for sr in graph.out_pred(&r.object, scc_core::predicates::CONTAINS) {
314 symbol_comp.insert(sr.object.clone(), c.id.clone());
315 }
316 }
317 }
318 symbol_comp
319}
320
321pub struct CompilationPipeline<'a> {
330 store: &'a Store,
331 component_signals: Vec<components::ComponentSignal>,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum CompilationStage {
336 Components,
337 Flows,
338 Behavior,
339 Invariants,
340 Drift,
341 Boundaries,
342}
343
344#[derive(Debug, Clone, Default, PartialEq, Eq)]
345pub struct StageCounts {
346 pub components: usize,
347 pub flows: usize,
348 pub invariants: usize,
349 pub drift: usize,
350 pub boundaries: usize,
351}
352
353impl<'a> CompilationPipeline<'a> {
355 pub fn new(store: &'a Store) -> CompilationPipeline<'a> {
357 CompilationPipeline { store, component_signals: Vec::new() }
358 }
359
360 pub fn component_signals(mut self, signals: Vec<components::ComponentSignal>) -> Self {
366 self.component_signals = signals;
367 self
368 }
369
370 pub fn run(self) -> Result<RecompileReport> {
372 self.store
374 .bump_epoch(scc_store::ModelEpochKind::Derived)?;
375
376 let graph = RealityGraph::load(self.store)?;
383 let intent = self.store.intent_claims()?;
384 let pairs = cochange::cached_cochange_pairs(self.store).unwrap_or_default();
390 let comps = components::compile_components_with_signals(&graph, self.store, &intent, &pairs, &self.component_signals)?;
391 self.store.replace_components(&comps)?;
392 cochange::enrich_components(self.store, &pairs).map_err(GraphError::Cochange)?;
393
394 let graph = RealityGraph::load(self.store)?;
396 let (seq_flows, data_flows, arch_flow) =
397 flows::compile_flows(&graph, self.store, &intent)?;
398 let mut all = seq_flows;
399 all.extend(data_flows);
400 if let Some(a) = arch_flow {
401 all.push(a);
402 }
403 self.store.replace_flows(&all)?;
404
405 let graph = RealityGraph::load(self.store)?;
410 let symbol_comp = symbol_component_map(&graph);
411 let graphs = flowgraph::compile_flow_graphs(&graph, self.store, &intent, &symbol_comp)?;
412 self.store.replace_flow_graphs(&graphs)?;
413 let graph = RealityGraph::load(self.store)?;
414 let mut lifecycles = lifecycle::compile_lifecycles(&graph, self.store)?;
415 let mut workflows = workflow::compile_workflows(&graph, self.store)?;
416 all.append(&mut lifecycles);
417 all.append(&mut workflows);
418 self.store.replace_flows(&all)?;
419
420 let graph = RealityGraph::load(self.store)?;
422 let invs = invariants::compile_invariants(&graph, &intent)?;
423 self.store.replace_invariants(&invs)?;
424
425 let graph = RealityGraph::load(self.store)?;
428 let stored_comps = self.store.components()?;
429 let findings = invariants::drift_findings(&graph, self.store, &intent, &stored_comps)?;
430 self.store.clear_drift_findings()?;
431 for (kind, severity, message) in &findings {
432 self.store
433 .add_drift_finding(kind, severity, message)?;
434 }
435
436 let graph = RealityGraph::load(self.store)?;
438 let crossings = boundaries::compile_boundaries(&graph, self.store)?;
439 for (rel, src) in crossings {
440 self.store.insert_relationship(&rel, &src)?;
441 }
442
443 self.store.sweep_orphan_evidence()?;
446
447 Ok(RecompileReport {
448 components: comps.len(),
449 flows: all.len(),
450 invariants: invs.len(),
451 drift: findings.len(),
452 boundaries: stored_comps.len(),
453 })
454 }
455}
456
457pub fn recompile_scoped(
467 store: &Store,
468 changed_files: &[String],
469 component_signals: Vec<components::ComponentSignal>,
470) -> Result<(RecompileReport, AffectedClosure)> {
471 recompile_scoped_with_owners(store, changed_files, &[], component_signals)
472}
473
474pub fn recompile_scoped_with_owners(
476 store: &Store,
477 changed_files: &[String],
478 known_owners: &[String],
479 component_signals: Vec<components::ComponentSignal>,
480) -> Result<(RecompileReport, AffectedClosure)> {
481 let graph = RealityGraph::load(store)?;
482 let closure = affected_closure_with_owners(&graph, changed_files, known_owners);
483 let report = CompilationPipeline::new(store)
484 .component_signals(component_signals)
485 .run()?;
486 Ok((report, closure))
487}
488
489pub fn recompile(store: &Store) -> Result<RecompileReport> {
494 CompilationPipeline::new(store).run()
495}
496
497#[derive(Debug, Clone, Default)]
498pub struct RecompileReport {
499 pub components: usize,
500 pub flows: usize,
501 pub invariants: usize,
502 pub drift: usize,
505 pub boundaries: usize,
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn load_empty_store() {
515 let dir = tempfile::TempDir::new().unwrap();
516 let root = dir.path().join("repo");
517 std::fs::create_dir_all(&root).unwrap();
518 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
519 let g = RealityGraph::load(&store).unwrap();
520 assert!(g.entities.is_empty());
521 let rep = recompile(&store).unwrap();
522 assert_eq!(rep.components, 1, "empty repos still get a root component");
523 }
524
525 #[test]
526 fn pipeline_bumps_derived_epoch_and_reloads_between_stages() {
527 let dir = tempfile::TempDir::new().unwrap();
528 let root = dir.path().join("repo");
529 std::fs::create_dir_all(&root).unwrap();
530 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
531
532 let epoch_before = store.model_epoch().unwrap();
533 let rep = CompilationPipeline::new(&store).run().unwrap();
534 let epoch_after = store.model_epoch().unwrap();
535
536 assert_eq!(epoch_after.derived, epoch_before.derived + 1);
539 assert!(rep.components >= 1);
540 assert_eq!(rep.drift, 0, "no drift on an empty model");
541
542 let rep2 = CompilationPipeline::new(&store).run().unwrap();
545 assert_eq!(rep2.components, rep.components);
546 assert_eq!(rep2.flows, rep.flows);
547 assert_eq!(rep2.invariants, rep.invariants);
548 }
549
550 #[test]
551 fn drift_uses_newly_compiled_flows() {
552 let dir = tempfile::TempDir::new().unwrap();
556 let root = dir.path().join("repo");
557 std::fs::create_dir_all(&root).unwrap();
558 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
559
560 let rep = CompilationPipeline::new(&store).run().unwrap();
562 let base_flows = rep.flows;
563 assert!(base_flows >= 1);
564
565 let repo = store.repo_id.clone();
567 let route = scc_core::entity_id(&repo, "route", "get-/api/x");
568 store
569 .insert_entity(
570 scc_core::Entity::new(route.clone(), "route", "get-/api/x")
571 .attr("method", serde_json::json!("GET"))
572 .attr("path", serde_json::json!("/api/x"))
573 .attr("handler", serde_json::json!("handle_x")),
574 &["main.py".into()],
575 )
576 .unwrap();
577 let sym = scc_core::symbol_id(&repo, "main.py", "handle_x");
578 store
579 .insert_entity(&scc_core::Entity::new(sym.clone(), "symbol", "handle_x"), &["main.py".into()])
580 .unwrap();
581 store
582 .insert_relationship(
583 &scc_core::Relationship::new(
584 "rel:route",
585 sym.clone(),
586 scc_core::predicates::HANDLES,
587 route,
588 scc_core::Provenance::Extracted,
589 ),
590 "main.py",
591 )
592 .unwrap();
593
594 let rep2 = CompilationPipeline::new(&store).run().unwrap();
597 assert!(rep2.flows > base_flows, "flows: {} vs {base_flows}", rep2.flows);
598 let flows = store.flows().unwrap();
599 assert!(
600 flows.iter().any(|f| f.name.contains("get-/api/x")),
601 "compiled flow must be present: {flows:?}"
602 );
603 }
604
605 fn git_init(dir: &std::path::Path) {
607 for args in [
608 vec!["init", "-q"],
609 vec!["config", "user.email", "test@example.com"],
610 vec!["config", "user.name", "SCC Test"],
611 vec!["config", "commit.gpgsign", "false"],
615 ] {
616 let out = std::process::Command::new("git")
617 .args(&args)
618 .current_dir(dir)
619 .output()
620 .unwrap();
621 assert!(out.status.success(), "git {args:?} failed");
622 }
623 }
624
625 fn git_commit_all(dir: &std::path::Path, msg: &str) {
626 let out = std::process::Command::new("git")
627 .args(["add", "-A"])
628 .current_dir(dir)
629 .output()
630 .unwrap();
631 assert!(out.status.success());
632 let out = std::process::Command::new("git")
633 .args(["commit", "-q", "-m", msg])
634 .current_dir(dir)
635 .output()
636 .unwrap();
637 assert!(out.status.success(), "git commit failed");
638 }
639
640 fn git_write(dir: &std::path::Path, name: &str, content: &str) {
641 let p = dir.join(name);
642 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
643 std::fs::write(p, content).unwrap();
644 }
645
646 #[test]
647 fn pipeline_wires_cochange_into_clustering() {
648 let dir = tempfile::TempDir::new().unwrap();
653 let root = dir.path().join("repo");
654 std::fs::create_dir_all(&root).unwrap();
655 git_init(&root);
656 git_write(&root, "src/a.py", "a = 1\n");
657 git_write(&root, "src/b.py", "b = 2\n");
658 git_commit_all(&root, "c1");
659 git_write(&root, "src/a.py", "a = 2\n");
660 git_write(&root, "src/b.py", "b = 3\n");
661 git_commit_all(&root, "c2");
662
663 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
664 for f in ["src/a.py", "src/b.py"] {
665 let id = scc_core::entity_id(&store.repo_id, scc_core::kinds::FILE, f);
666 store
667 .insert_entity(
668 &scc_core::Entity::new(id, scc_core::kinds::FILE, f),
669 &[f.into()],
670 )
671 .unwrap();
672 }
673 store
674 .replace_intent_claims(&[(
675 "component".to_string(),
676 serde_json::json!({"name": "core", "paths": ["src"]}),
677 )])
678 .unwrap();
679
680 let rep = CompilationPipeline::new(&store).run().unwrap();
681 assert!(rep.components >= 2, "root + core: {}", rep.components);
682
683 let comps = store.components().unwrap();
684 let core = comps.iter().find(|c| c.name == "core").unwrap();
685 assert_eq!(
686 core.attributes["boundary_kind"],
687 serde_json::json!("declared")
688 );
689 assert_eq!(
691 core.attributes["clustering_score"],
692 serde_json::json!(2.0),
693 "{:?}",
694 core.attributes
695 );
696 let cc = core.attributes["cochange"].clone();
698 assert_eq!(cc["top"], 2);
699 assert!(
700 cc["pairs"][0]
701 .as_str()
702 .unwrap()
703 .starts_with("src/a.py <-> src/b.py"),
704 "{cc}"
705 );
706 let rep2 = CompilationPipeline::new(&store).run().unwrap();
708 assert_eq!(rep2.components, rep.components);
709 let comps2 = store.components().unwrap();
710 let core2 = comps2.iter().find(|c| c.name == "core").unwrap();
711 assert_eq!(
712 core.attributes["clustering_score"],
713 core2.attributes["clustering_score"]
714 );
715 }
716}