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 fn symbol_component_map(graph: &RealityGraph) -> HashMap<String, String> {
140 let mut symbol_comp: HashMap<String, String> = HashMap::new();
141 for c in &graph.components {
142 for r in graph.out_pred(&c.id, scc_core::predicates::CONTAINS) {
143 for sr in graph.out_pred(&r.object, scc_core::predicates::CONTAINS) {
144 symbol_comp.insert(sr.object.clone(), c.id.clone());
145 }
146 }
147 }
148 symbol_comp
149}
150
151pub struct CompilationPipeline<'a> {
160 store: &'a Store,
161 component_signals: Vec<components::ComponentSignal>,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum CompilationStage {
166 Components,
167 Flows,
168 Behavior,
169 Invariants,
170 Drift,
171 Boundaries,
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct StageCounts {
176 pub components: usize,
177 pub flows: usize,
178 pub invariants: usize,
179 pub drift: usize,
180 pub boundaries: usize,
181}
182
183impl<'a> CompilationPipeline<'a> {
185 pub fn new(store: &'a Store) -> CompilationPipeline<'a> {
187 CompilationPipeline { store, component_signals: Vec::new() }
188 }
189
190 pub fn component_signals(mut self, signals: Vec<components::ComponentSignal>) -> Self {
196 self.component_signals = signals;
197 self
198 }
199
200 pub fn run(self) -> Result<RecompileReport> {
202 self.store
204 .bump_epoch(scc_store::ModelEpochKind::Derived)?;
205
206 let graph = RealityGraph::load(self.store)?;
213 let intent = self.store.intent_claims()?;
214 let pairs = cochange::cached_cochange_pairs(self.store).unwrap_or_default();
220 let comps = components::compile_components_with_signals(&graph, self.store, &intent, &pairs, &self.component_signals)?;
221 self.store.replace_components(&comps)?;
222 cochange::enrich_components(self.store, &pairs).map_err(GraphError::Cochange)?;
223
224 let graph = RealityGraph::load(self.store)?;
226 let (seq_flows, data_flows, arch_flow) =
227 flows::compile_flows(&graph, self.store, &intent)?;
228 let mut all = seq_flows;
229 all.extend(data_flows);
230 if let Some(a) = arch_flow {
231 all.push(a);
232 }
233 self.store.replace_flows(&all)?;
234
235 let graph = RealityGraph::load(self.store)?;
240 let symbol_comp = symbol_component_map(&graph);
241 let graphs = flowgraph::compile_flow_graphs(&graph, self.store, &intent, &symbol_comp)?;
242 self.store.replace_flow_graphs(&graphs)?;
243 let graph = RealityGraph::load(self.store)?;
244 let mut lifecycles = lifecycle::compile_lifecycles(&graph, self.store)?;
245 let mut workflows = workflow::compile_workflows(&graph, self.store)?;
246 all.append(&mut lifecycles);
247 all.append(&mut workflows);
248 self.store.replace_flows(&all)?;
249
250 let graph = RealityGraph::load(self.store)?;
252 let invs = invariants::compile_invariants(&graph, &intent)?;
253 self.store.replace_invariants(&invs)?;
254
255 let graph = RealityGraph::load(self.store)?;
258 let stored_comps = self.store.components()?;
259 let findings = invariants::drift_findings(&graph, self.store, &intent, &stored_comps)?;
260 self.store.clear_drift_findings()?;
261 for (kind, severity, message) in &findings {
262 self.store
263 .add_drift_finding(kind, severity, message)?;
264 }
265
266 let graph = RealityGraph::load(self.store)?;
268 let crossings = boundaries::compile_boundaries(&graph, self.store)?;
269 for (rel, src) in crossings {
270 self.store.insert_relationship(&rel, &src)?;
271 }
272
273 self.store.sweep_orphan_evidence()?;
276
277 Ok(RecompileReport {
278 components: comps.len(),
279 flows: all.len(),
280 invariants: invs.len(),
281 drift: findings.len(),
282 boundaries: stored_comps.len(),
283 })
284 }
285}
286
287pub fn recompile(store: &Store) -> Result<RecompileReport> {
292 CompilationPipeline::new(store).run()
293}
294
295#[derive(Debug, Clone, Default)]
296pub struct RecompileReport {
297 pub components: usize,
298 pub flows: usize,
299 pub invariants: usize,
300 pub drift: usize,
303 pub boundaries: usize,
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn load_empty_store() {
313 let dir = tempfile::TempDir::new().unwrap();
314 let root = dir.path().join("repo");
315 std::fs::create_dir_all(&root).unwrap();
316 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
317 let g = RealityGraph::load(&store).unwrap();
318 assert!(g.entities.is_empty());
319 let rep = recompile(&store).unwrap();
320 assert_eq!(rep.components, 1, "empty repos still get a root component");
321 }
322
323 #[test]
324 fn pipeline_bumps_derived_epoch_and_reloads_between_stages() {
325 let dir = tempfile::TempDir::new().unwrap();
326 let root = dir.path().join("repo");
327 std::fs::create_dir_all(&root).unwrap();
328 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
329
330 let epoch_before = store.model_epoch().unwrap();
331 let rep = CompilationPipeline::new(&store).run().unwrap();
332 let epoch_after = store.model_epoch().unwrap();
333
334 assert_eq!(epoch_after.derived, epoch_before.derived + 1);
337 assert!(rep.components >= 1);
338 assert_eq!(rep.drift, 0, "no drift on an empty model");
339
340 let rep2 = CompilationPipeline::new(&store).run().unwrap();
343 assert_eq!(rep2.components, rep.components);
344 assert_eq!(rep2.flows, rep.flows);
345 assert_eq!(rep2.invariants, rep.invariants);
346 }
347
348 #[test]
349 fn drift_uses_newly_compiled_flows() {
350 let dir = tempfile::TempDir::new().unwrap();
354 let root = dir.path().join("repo");
355 std::fs::create_dir_all(&root).unwrap();
356 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
357
358 let rep = CompilationPipeline::new(&store).run().unwrap();
360 let base_flows = rep.flows;
361 assert!(base_flows >= 1);
362
363 let repo = store.repo_id.clone();
365 let route = scc_core::entity_id(&repo, "route", "get-/api/x");
366 store
367 .insert_entity(
368 scc_core::Entity::new(route.clone(), "route", "get-/api/x")
369 .attr("method", serde_json::json!("GET"))
370 .attr("path", serde_json::json!("/api/x"))
371 .attr("handler", serde_json::json!("handle_x")),
372 &["main.py".into()],
373 )
374 .unwrap();
375 let sym = scc_core::symbol_id(&repo, "main.py", "handle_x");
376 store
377 .insert_entity(&scc_core::Entity::new(sym.clone(), "symbol", "handle_x"), &["main.py".into()])
378 .unwrap();
379 store
380 .insert_relationship(
381 &scc_core::Relationship::new(
382 "rel:route",
383 sym.clone(),
384 scc_core::predicates::HANDLES,
385 route,
386 scc_core::Provenance::Extracted,
387 ),
388 "main.py",
389 )
390 .unwrap();
391
392 let rep2 = CompilationPipeline::new(&store).run().unwrap();
395 assert!(rep2.flows > base_flows, "flows: {} vs {base_flows}", rep2.flows);
396 let flows = store.flows().unwrap();
397 assert!(
398 flows.iter().any(|f| f.name.contains("get-/api/x")),
399 "compiled flow must be present: {flows:?}"
400 );
401 }
402
403 fn git_init(dir: &std::path::Path) {
405 for args in [
406 vec!["init", "-q"],
407 vec!["config", "user.email", "test@example.com"],
408 vec!["config", "user.name", "SCC Test"],
409 vec!["config", "commit.gpgsign", "false"],
413 ] {
414 let out = std::process::Command::new("git")
415 .args(&args)
416 .current_dir(dir)
417 .output()
418 .unwrap();
419 assert!(out.status.success(), "git {args:?} failed");
420 }
421 }
422
423 fn git_commit_all(dir: &std::path::Path, msg: &str) {
424 let out = std::process::Command::new("git")
425 .args(["add", "-A"])
426 .current_dir(dir)
427 .output()
428 .unwrap();
429 assert!(out.status.success());
430 let out = std::process::Command::new("git")
431 .args(["commit", "-q", "-m", msg])
432 .current_dir(dir)
433 .output()
434 .unwrap();
435 assert!(out.status.success(), "git commit failed");
436 }
437
438 fn git_write(dir: &std::path::Path, name: &str, content: &str) {
439 let p = dir.join(name);
440 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
441 std::fs::write(p, content).unwrap();
442 }
443
444 #[test]
445 fn pipeline_wires_cochange_into_clustering() {
446 let dir = tempfile::TempDir::new().unwrap();
451 let root = dir.path().join("repo");
452 std::fs::create_dir_all(&root).unwrap();
453 git_init(&root);
454 git_write(&root, "src/a.py", "a = 1\n");
455 git_write(&root, "src/b.py", "b = 2\n");
456 git_commit_all(&root, "c1");
457 git_write(&root, "src/a.py", "a = 2\n");
458 git_write(&root, "src/b.py", "b = 3\n");
459 git_commit_all(&root, "c2");
460
461 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
462 for f in ["src/a.py", "src/b.py"] {
463 let id = scc_core::entity_id(&store.repo_id, scc_core::kinds::FILE, f);
464 store
465 .insert_entity(
466 &scc_core::Entity::new(id, scc_core::kinds::FILE, f),
467 &[f.into()],
468 )
469 .unwrap();
470 }
471 store
472 .replace_intent_claims(&[(
473 "component".to_string(),
474 serde_json::json!({"name": "core", "paths": ["src"]}),
475 )])
476 .unwrap();
477
478 let rep = CompilationPipeline::new(&store).run().unwrap();
479 assert!(rep.components >= 2, "root + core: {}", rep.components);
480
481 let comps = store.components().unwrap();
482 let core = comps.iter().find(|c| c.name == "core").unwrap();
483 assert_eq!(
484 core.attributes["boundary_kind"],
485 serde_json::json!("declared")
486 );
487 assert_eq!(
489 core.attributes["clustering_score"],
490 serde_json::json!(2.0),
491 "{:?}",
492 core.attributes
493 );
494 let cc = core.attributes["cochange"].clone();
496 assert_eq!(cc["top"], 2);
497 assert!(
498 cc["pairs"][0]
499 .as_str()
500 .unwrap()
501 .starts_with("src/a.py <-> src/b.py"),
502 "{cc}"
503 );
504 let rep2 = CompilationPipeline::new(&store).run().unwrap();
506 assert_eq!(rep2.components, rep.components);
507 let comps2 = store.components().unwrap();
508 let core2 = comps2.iter().find(|c| c.name == "core").unwrap();
509 assert_eq!(
510 core.attributes["clustering_score"],
511 core2.attributes["clustering_score"]
512 );
513 }
514}