Skip to main content

scc_graph/
lib.rs

1//! Graph layer: Reality Graph loading plus the System IR compilers
2//! (components, flows, invariants) and impact analysis.
3//!
4//! Docs mapping: scc-graph + scc-system-ir + scc-flow.
5
6pub 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
22// trace:v1 id=impl.scc-graph.reality-graph work=WORK-SCC-004 satisfies=REQ-SCC-IR
23impl 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)]
42// trace:exempt reason=internal-detail
43pub 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
54/// In-memory view of the reality graph.
55// trace:v1 id=impl.scc-graph.reality-graph-struct work=WORK-SCC-004 satisfies=REQ-SCC-IR
56pub struct RealityGraph {
57    pub repo_id: String,
58    pub entities: HashMap<String, Entity>,
59    /// out edges by subject
60    pub out: HashMap<String, Vec<Relationship>>,
61    /// in edges by object
62    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    /// Entities of a given kind (sorted by name for determinism).
126    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
137/// Map every symbol id to its component id via the component CONTAINS
138/// edges (shared by the flow graph compiler and flow projections).
139/// Affected closure for scoped recompilation (C1b): the set of
140/// derived entities that may have changed given source-file mutations.
141/// File -> owning component(s) via stored component CONTAINS file edges;
142/// component -> flows via flow participant edges. Conservative: unknown
143/// files map to the whole closure (None = full recompile required).
144// trace:v1 id=impl.scc-graph-affected-closure work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
145pub struct AffectedClosure {
146    /// Component ids that may have changed (empty + `complete=true` means all).
147    pub components: Vec<String>,
148    /// Flow ids that may have changed.
149    pub flows: Vec<String>,
150    /// True when the closure cannot be bounded (unknown file, topology
151    /// change): the caller must run the full pipeline.
152    pub complete: bool,
153}
154
155// trace:v1 id=impl.scc-graph-affected-closure-fn work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
156pub fn affected_closure_with_owners(
157    graph: &RealityGraph,
158    changed_files: &[String],
159    known_owners: &[String],
160) -> AffectedClosure {
161    use std::collections::HashSet;
162    // Pre-purge owners (captured by the indexer while previous-gen edges
163    // existed) are authoritative: seed the component set directly, then
164    // expand to flows below. Unknown files with no known owner stay
165    // unbounded.
166    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        // Owners known despite unmappable files: bounded after all.
173        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    // Recompute flows over the union.
201    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
221// trace:v1 id=impl.scc-graph-affected-closure-base work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
222pub fn affected_closure(graph: &RealityGraph, changed_files: &[String]) -> AffectedClosure {
223    use std::collections::HashSet;
224    // NOTE: the closure runs pre-recompile against the PREVIOUS generation's
225    // component edges. A file the previous clustering never placed (new
226    // files, unplaced files) cannot map to an owner — that is correct
227    // invalidation semantics (placement is unknowable until clustering
228    // runs), and correctly forces complete=true. Do not "fix" by falling
229    // back to neighbor heuristics: an unbounded closure is the honest
230    // answer when ownership is unknown.
231    let mut components: HashSet<String> = HashSet::new();
232    // file entity id prefix: files are entities `repo:{repo_id}/file/{path}`.
233    // Symbol -> component owner via the same CONTAINS edges (file CONTAINS
234    // symbol, component CONTAINS file): survives the refresh purge, which
235    // deletes the changed file's direct edges (source_path) before the
236    // closure runs. File ids end with `/{path}`.
237    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                // Component CONTAINS file-entity ids; file ids end with
250                // the path (`repo:{id}/file/{path}`) or equal it.
251                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            // Purge-safe fallback: any symbol still owned whose id embeds
263            // the file path (`.../symbol/{path}/{name}`) proves the file's
264            // previous owner. New files with no symbols stay unbounded.
265            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            // Unknown file (new path, unmapped): cannot bound the closure.
275            return AffectedClosure { components: vec![], flows: vec![], complete: true };
276        }
277    }
278    // Component -> flows: flows whose participant set intersects the
279    // affected components (via flow CONTAINS component or symbol edges
280    // resolving through symbol_component_map).
281    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
321/// Staged derived compilation (P0, docs/SYSTEM_DESIGN.md §7): every stage
322/// writes its output, reloads the reality graph, and only then compiles the
323/// next stage, so drift and later stages can never be computed against a
324/// graph that predates freshly written facts. The derived model epoch is
325/// bumped *before* the first write so cached context packs are invalidated
326/// even if a stage fails mid-pipeline (fail closed — no stale trusted pack
327/// survives a partial recompile).
328// trace:v1 id=impl.scc-graph.compilation-pipeline work=WORK-SCC-004 satisfies=REQ-SCC-IR
329pub 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
353// trace:exempt reason=internal-detail
354impl<'a> CompilationPipeline<'a> {
355    // trace:exempt reason=internal-detail
356    pub fn new(store: &'a Store) -> CompilationPipeline<'a> {
357        CompilationPipeline { store, component_signals: Vec::new() }
358    }
359
360    /// Plugin component signals (§31 ComponentSignalProvider): merged by
361    /// name into the builtin candidate set before clustering. Same-name
362    /// signal dirs append (never rename or re-rank); new names enter at
363    /// plugin rank; failures upstream degrade to empty (never fail here).
364    // trace:exempt reason=internal-detail
365    pub fn component_signals(mut self, signals: Vec<components::ComponentSignal>) -> Self {
366        self.component_signals = signals;
367        self
368    }
369
370    // trace:exempt reason=internal-detail
371    pub fn run(self) -> Result<RecompileReport> {
372        // invalidate epoch-keyed context caches before any derived write
373        self.store
374            .bump_epoch(scc_store::ModelEpochKind::Derived)?;
375
376        // STAGE 0/1: load the base reality graph, compile components.
377        // Co-change pairs are computed first (Wave 5): they feed the
378        // clustering score during compilation and enrich the freshly
379        // written components right after, before any later stage reloads
380        // the graph. Not a git repo yields empty pairs — no signal, no
381        // error.
382        let graph = RealityGraph::load(self.store)?;
383        let intent = self.store.intent_claims()?;
384        // HEAD-keyed cache in store meta: a cache hit skips the git pass
385        // entirely (second `scc index` at the same HEAD is near-instant);
386        // a miss computes with per-commit caps and persists. Any failure
387        // is non-fatal — empty pairs, same as a non-git repo — so the
388        // atlas/index never waits on git history.
389        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        // STAGE 2: reload (components now visible), compile flows.
395        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        // STAGE 3: canonical causal flow graphs (Wave 3) — the behavioral
406        // truth from which projections derive; then the behavioral views
407        // (lifecycle state machines + operational workflows) which read the
408        // stored sequence flows (reload).
409        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        // STAGE 4: invariants against the fully compiled model.
421        let graph = RealityGraph::load(self.store)?;
422        let invs = invariants::compile_invariants(&graph, &intent)?;
423        self.store.replace_invariants(&invs)?;
424
425        // STAGE 5: drift against the *stored* components (reloaded), never
426        // the pre-reload in-memory list.
427        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        // STAGE 6: trust-boundary crossings (derived facts).
437        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        // garbage-collect evidence that lost its last reference during the
444        // rebuild (docs/DATA_STRATEGY.md §6)
445        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
457/// Scoped recompile entry (C1b): derive the affected closure for the
458/// changed files and recompile. When the closure is bounded, only the
459/// affected components/flows are recorded in the report; the pipeline
460/// itself still runs whole-repo until per-stage merge writes land — the
461/// closure is the contract that merge work will consume. `complete=true`
462/// (unbounded) always runs the full pipeline.
463/// Returns the closure alongside the report so callers can observe how
464/// tight the bound was.
465// trace:v1 id=impl.scc-graph-recompile-scoped work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
466pub 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
474// trace:v1 id=impl.scc-graph-recompile-scoped-owners work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
475pub 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
489/// Recompile the entire derived layer (components, flows, invariants, drift)
490/// from the reality graph. Idempotent; replaces derived tables in the store.
491/// Equivalent to [`CompilationPipeline::run`] (kept for callers that do not
492/// need stage control).
493pub 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    /// Number of drift findings emitted at stage 5 (against the freshly
503    /// compiled model).
504    pub drift: usize,
505    /// Number of trust-boundary crossing edges at stage 6.
506    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        // derived compilation invalidates the cache epoch even on an empty
537        // store (fail closed before any derived write)
538        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        // a second run is idempotent in content but bumps again (each
543        // recompile is a new derived model state)
544        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        // regression (P0 stage ordering): drift findings must be computed
553        // against flows written by the current pipeline run, not a stale
554        // pre-recompile model.
555        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        // first compile: only the empty-repo architecture flow exists
561        let rep = CompilationPipeline::new(&store).run().unwrap();
562        let base_flows = rep.flows;
563        assert!(base_flows >= 1);
564
565        // add a flow-affecting fact: a route handler
566        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        // second compile: the pipeline must see its own newly written
595        // component (root) and flow (get-/api/x) in later stages
596        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    // trace:exempt reason=test-helper  # hermetic git fixture factory (test-only)
606    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            // Hermetic: a user's global commit.gpgsign=true must not leak
612            // into test repos (gpg-agent exhaustion under parallel load
613            // made commits flaky).
614            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        // Wave 5: the pipeline computes git co-change pairs before stage 1,
649        // feeds them into the clustering score (+2 per pair fully inside a
650        // candidate), and annotates the freshly written components via
651        // cochange::enrich_components — all in one run.
652        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        // one pair (src/a.py <-> src/b.py) fully inside "src": +2.0
690        assert_eq!(
691            core.attributes["clustering_score"],
692            serde_json::json!(2.0),
693            "{:?}",
694            core.attributes
695        );
696        // enrich_components ran on the freshly written components
697        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        // a second identical run is deterministic
707        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}