Skip to main content

scc_graph/
lifecycle.rs

1//! Lifecycle Compiler (EPIC-050, P1 "system semantics", docs/EPICS_AND_TICKETS.md).
2//!
3//! Detects state machines deterministically from the symbol index and emits
4//! one Lifecycle flow per component that owns >= 2 state-machine signals.
5//!
6//! Signals: class/enum symbols with state-machine-ish names, signatures that
7//! mention `Enum`/`StrEnum`, and transition-verb symbol names.
8
9use crate::components::{component_for_path, ComponentCandidate};
10use crate::{RealityGraph, Result};
11use scc_core::kinds;
12use scc_core::{entity_id, Entity, Flow, FlowKind, FlowStep, Provenance};
13use scc_store::Store;
14use serde_json::json;
15use std::collections::BTreeMap;
16
17/// Component candidates built from compiled component implementation paths
18/// (longest dir-prefix match wins, same rule as flows.rs).
19// trace:exempt reason=internal-detail
20pub fn component_candidates(graph: &RealityGraph) -> Vec<ComponentCandidate> {
21    let mut candidates: Vec<ComponentCandidate> = Vec::new();
22    for c in &graph.components {
23        let mut dirs: Vec<String> = Vec::new();
24        if let Some(paths) = c
25            .attributes
26            .get("implementation")
27            .and_then(|i| i.get("paths"))
28            .and_then(|p| p.as_array())
29        {
30            for p in paths {
31                if let Some(s) = p.as_str() {
32                    dirs.push(s.to_string());
33                }
34            }
35        }
36        if dirs.is_empty() {
37            dirs.push(c.name.clone());
38        }
39        candidates.push(ComponentCandidate {
40            name: c.name.clone(),
41            dirs,
42            boundary_kind: c
43                .attributes
44                .get("boundary_kind")
45                .and_then(|v| v.as_str())
46                .map(|s| s.to_string())
47                .unwrap_or_else(|| crate::components::BOUNDARY_CODE_REGION.to_string()), intent: None,
48        });
49    }
50    candidates
51}
52
53/// A symbol is a state-machine signal when its kind/name/signature matches a
54/// state-machine convention or its name is a transition verb.
55fn is_signal(e: &Entity) -> bool {
56    let name_l = e.name.to_ascii_lowercase();
57    let sym_kind = e.attributes.get("kind").and_then(|v| v.as_str()).unwrap_or("");
58    // (a) class/enum whose name ends with State/Status/Stage or contains
59    //     StateMachine/Machine/FSM
60    if matches!(sym_kind, "class" | "enum")
61        && (name_l.ends_with("state")
62            || name_l.ends_with("status")
63            || name_l.ends_with("stage")
64            || name_l.contains("statemachine")
65            || name_l.contains("machine")
66            || name_l.contains("fsm"))
67        {
68            return true;
69        }
70    // (b) signature mentions Enum/StrEnum
71    if let Some(sig) = e.attributes.get("signature").and_then(|v| v.as_str()) {
72        if sig.contains("Enum") || sig.contains("StrEnum") {
73            return true;
74        }
75    }
76    // (c) transition-verb name
77    is_transition_verb(&e.name)
78}
79
80/// The unqualified name of a symbol: methods are stored as
81/// `Class.method`, and verb detection must look at the method part.
82fn base_name(name: &str) -> &str {
83    name.rsplit('.').next().unwrap_or(name)
84}
85
86fn is_transition_verb(name: &str) -> bool {
87    let n = base_name(name).to_ascii_lowercase();
88    matches!(
89        n.as_str(),
90        "advance"
91            | "transition"
92            | "set_status"
93            | "set_state"
94            | "move_to"
95            | "next_state"
96            | "retry"
97            | "cancel"
98            | "fail"
99            | "complete"
100            | "pause"
101            | "resume"
102    ) || n.starts_with("mark_")
103}
104
105/// Map a transition verb to its lifecycle condition category.
106fn verb_category(name: &str) -> &'static str {
107    let n = base_name(name).to_ascii_lowercase();
108    if n == "retry" {
109        "retry"
110    } else if n.starts_with("mark_") || n.starts_with("set_") {
111        "event"
112    } else if n.starts_with("transition")
113        || n.starts_with("advance")
114        || n.starts_with("move")
115        || n.starts_with("next")
116    {
117        "transition"
118    } else if n == "cancel" || n == "fail" || n == "complete" {
119        "terminal outcome"
120    } else {
121        // pause/resume and state-named signals stay plain "state"
122        "state"
123    }
124}
125
126/// Emit one Lifecycle flow per component with >= 2 state-machine signals.
127// trace:v1 id=impl.scc.lifecycle work=WORK-SCC-013 satisfies=REQ-SCC-IR
128pub fn compile_lifecycles(graph: &RealityGraph, store: &Store) -> Result<Vec<Flow>> {
129    let candidates = component_candidates(graph);
130    let comp_name_to_id: BTreeMap<String, String> = graph
131        .components
132        .iter()
133        .map(|c| (c.name.clone(), c.id.clone()))
134        .collect();
135
136    // symbol file -> component (same mapping as flows.rs)
137    let mut signals_by_comp: BTreeMap<String, Vec<&Entity>> = BTreeMap::new();
138    for e in graph.entities_of_kind(kinds::SYMBOL) {
139        if !is_signal(e) {
140            continue;
141        }
142        let comp = e
143            .attributes
144            .get("file")
145            .and_then(|v| v.as_str())
146            .map(|f| component_for_path(f, &candidates))
147            .unwrap_or_else(|| "root".to_string());
148        signals_by_comp.entry(comp).or_default().push(e);
149    }
150
151    let mut out: Vec<Flow> = Vec::new();
152    for (comp, mut signals) in signals_by_comp {
153        if signals.len() < 2 {
154            continue; // no empty/trivial lifecycles
155        }
156        signals.sort_by(|a, b| a.name.cmp(&b.name));
157        let comp_id = comp_name_to_id
158            .get(&comp)
159            .cloned()
160            .unwrap_or_else(|| format!("component:{comp}"));
161        let name = format!("{comp}-lifecycle");
162
163        let mut steps: Vec<FlowStep> = Vec::new();
164        steps.push(FlowStep {
165            id: "step:1".to_string(),
166            order: 1,
167            actor: comp_id.clone(),
168            operation: comp.clone(),
169            condition: None,
170            r#async: None,
171            timeout_ms: None,
172            retry_policy: None,
173            failure_outcome: None,
174            provenance: Some(Provenance::Inferred),
175            evidence: Vec::new(),
176        });
177        let mut states: Vec<String> = Vec::new();
178        let mut transitions: Vec<String> = Vec::new();
179        for (i, s) in signals.iter().enumerate() {
180            let cat = verb_category(&s.name);
181            states.push(s.name.clone());
182            if cat == "transition" {
183                transitions.push(s.name.clone());
184            }
185            steps.push(FlowStep {
186                id: format!("step:{}", i + 2),
187                order: (i + 2) as u32,
188                actor: comp_id.clone(),
189                operation: s.name.clone(),
190                condition: Some(cat.to_string()),
191                r#async: None,
192                timeout_ms: None,
193                retry_policy: None,
194                failure_outcome: None,
195                provenance: Some(Provenance::Extracted),
196                evidence: vec![s.id.clone()],
197            });
198        }
199
200        let mut attributes = BTreeMap::new();
201        attributes.insert("states".to_string(), json!(states));
202        attributes.insert("transitions".to_string(), json!(transitions));
203        attributes.insert("signals".to_string(), json!(signals.len()));
204        // Wave 3 §22: this view detects state-machine SIGNALS (enums,
205        // transition-verb names). It is NOT an authoritative state-machine
206        // ordering — the flag keeps agents from reading it as one.
207        attributes.insert("signals_only".to_string(), json!(true));
208        out.push(Flow {
209            id: entity_id(&store.repo_id, kinds::FLOW, &name),
210            kind: FlowKind::Lifecycle,
211            name,
212            trigger: None,
213            steps,
214            attributes,
215        });
216    }
217    out.sort_by(|a, b| a.name.cmp(&b.name));
218    Ok(out)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use scc_core::symbol_id;
225
226    fn setup() -> (tempfile::TempDir, Store) {
227        let dir = tempfile::TempDir::new().unwrap();
228        let root = dir.path().join("repo");
229        std::fs::create_dir_all(&root).unwrap();
230        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
231        (dir, store)
232    }
233
234    fn put_components(store: &Store, comps: &[(&str, &[&str])]) {
235        let entities: Vec<Entity> = comps
236            .iter()
237            .map(|(name, paths)| {
238                let mut c = Entity::new(
239                    entity_id(&store.repo_id, kinds::COMPONENT, name),
240                    kinds::COMPONENT,
241                    *name,
242                );
243                c.attr("implementation", json!({ "paths": paths, "symbols": [] }));
244                c
245            })
246            .collect();
247        store.replace_components(&entities).unwrap();
248    }
249
250    /// Insert a symbol entity; returns its id.
251    fn put_symbol(
252        store: &Store,
253        name: &str,
254        file: &str,
255        kind: &str,
256        attrs: &[(&str, serde_json::Value)],
257    ) -> String {
258        let mut e = Entity::new(symbol_id(&store.repo_id, file, name), kinds::SYMBOL, name);
259        e.attr("kind", serde_json::json!(kind));
260        e.attr("file", serde_json::json!(file));
261        for (k, v) in attrs {
262            e.attr(k, v.clone());
263        }
264        store.insert_entity(&e, &[file.to_string()]).unwrap();
265        e.id.clone()
266    }
267
268    #[test]
269    fn python_enum_class_and_transition_methods() {
270        let (_dir, store) = setup();
271        put_components(&store, &[("worker", &["worker"])]);
272        let state_id = put_symbol(&store, "OrderState", "worker/state_machine.py", "class", &[]);
273        let advance_id = put_symbol(&store, "advance", "worker/state_machine.py", "method", &[]);
274        let cancel_id = put_symbol(&store, "cancel", "worker/state_machine.py", "method", &[]);
275        // non-signal noise in the same component
276        put_symbol(&store, "process_order", "worker/state_machine.py", "function", &[]);
277        let graph = RealityGraph::load(&store).unwrap();
278
279        let flows = compile_lifecycles(&graph, &store).unwrap();
280        assert_eq!(flows.len(), 1);
281        let f = &flows[0];
282        assert_eq!(f.kind, FlowKind::Lifecycle);
283        assert_eq!(f.name, "worker-lifecycle");
284        assert_eq!(f.id, entity_id(&store.repo_id, kinds::FLOW, "worker-lifecycle"));
285        // entry + 3 signals, sorted by signal name
286        assert_eq!(f.steps.len(), 4);
287        let entry = &f.steps[0];
288        assert_eq!(entry.actor, entity_id(&store.repo_id, kinds::COMPONENT, "worker"));
289        assert_eq!(entry.operation, "worker");
290        assert_eq!(entry.provenance, Some(Provenance::Inferred));
291        let sigs: Vec<&FlowStep> = f.steps[1..].iter().collect();
292        assert_eq!(sigs[0].operation, "OrderState");
293        assert_eq!(sigs[0].condition.as_deref(), Some("state"));
294        assert_eq!(sigs[0].evidence, vec![state_id]);
295        assert_eq!(sigs[1].operation, "advance");
296        assert_eq!(sigs[1].condition.as_deref(), Some("transition"));
297        assert_eq!(sigs[1].evidence, vec![advance_id]);
298        assert_eq!(sigs[2].operation, "cancel");
299        assert_eq!(sigs[2].condition.as_deref(), Some("terminal outcome"));
300        assert_eq!(sigs[2].evidence, vec![cancel_id]);
301        for (i, s) in sigs.iter().enumerate() {
302            assert_eq!(s.actor, entry.actor);
303            assert_eq!(s.provenance, Some(Provenance::Extracted));
304            assert_eq!(s.order, i as u32 + 2);
305        }
306        assert_eq!(
307            f.attributes["states"],
308            json!(["OrderState", "advance", "cancel"])
309        );
310        assert_eq!(f.attributes["transitions"], json!(["advance"]));
311        assert_eq!(f.attributes["signals"], json!(3));
312    }
313
314    #[test]
315    fn ts_enum_and_signature_signal() {
316        let (_dir, store) = setup();
317        put_components(&store, &[("web", &["web"])]);
318        let status_id = put_symbol(&store, "ConnectionStatus", "web/conn.ts", "enum", &[]);
319        let kind_id = put_symbol(
320            &store,
321            "OrderKind",
322            "web/conn.ts",
323            "class",
324            &[("signature", serde_json::json!("class OrderKind(StrEnum)"))],
325        );
326        let set_id = put_symbol(&store, "set_status", "web/conn.ts", "function", &[]);
327        let retry_id = put_symbol(&store, "retry", "web/conn.ts", "function", &[]);
328        let graph = RealityGraph::load(&store).unwrap();
329
330        let flows = compile_lifecycles(&graph, &store).unwrap();
331        assert_eq!(flows.len(), 1);
332        let f = &flows[0];
333        assert_eq!(f.name, "web-lifecycle");
334        // entry + ConnectionStatus + OrderKind + retry + set_status
335        assert_eq!(f.steps.len(), 5);
336        let ops: Vec<&str> = f.steps[1..].iter().map(|s| s.operation.as_str()).collect();
337        assert_eq!(ops, vec!["ConnectionStatus", "OrderKind", "retry", "set_status"]);
338        let cond = |op: &str| {
339            f.steps
340                .iter()
341                .find(|s| s.operation == op)
342                .unwrap()
343                .condition
344                .clone()
345        };
346        assert_eq!(cond("ConnectionStatus").as_deref(), Some("state"));
347        assert_eq!(cond("OrderKind").as_deref(), Some("state"));
348        assert_eq!(cond("retry").as_deref(), Some("retry"));
349        assert_eq!(cond("set_status").as_deref(), Some("event"));
350        assert_eq!(f.attributes["signals"], json!(4));
351        assert_eq!(f.attributes["transitions"], json!([]));
352        assert_eq!(f.attributes["states"], json!(["ConnectionStatus", "OrderKind", "retry", "set_status"]));
353        let evidence: Vec<&str> = f.steps[1..]
354            .iter()
355            .map(|s| s.evidence[0].as_str())
356            .collect();
357        assert_eq!(
358            evidence,
359            vec![status_id.as_str(), kind_id.as_str(), retry_id.as_str(), set_id.as_str()]
360        );
361    }
362
363    #[test]
364    fn no_signals_emits_nothing() {
365        let (_dir, store) = setup();
366        put_components(&store, &[("api", &["api"])]);
367        put_symbol(&store, "process_order", "api/orders.py", "function", &[]);
368        put_symbol(&store, "Order", "api/orders.py", "class", &[]);
369        put_symbol(&store, "dispatch", "api/orders.py", "function", &[]);
370        let graph = RealityGraph::load(&store).unwrap();
371        let flows = compile_lifecycles(&graph, &store).unwrap();
372        assert!(flows.is_empty());
373    }
374
375    #[test]
376    fn single_signal_emits_nothing() {
377        let (_dir, store) = setup();
378        put_components(&store, &[("api", &["api"])]);
379        put_symbol(&store, "OrderState", "api/orders.py", "class", &[]);
380        let graph = RealityGraph::load(&store).unwrap();
381        let flows = compile_lifecycles(&graph, &store).unwrap();
382        assert!(flows.is_empty());
383    }
384
385    #[test]
386    fn lifecycle_per_component() {
387        let (_dir, store) = setup();
388        put_components(&store, &[("a", &["a"]), ("b", &["b"])]);
389        // component a: 2 signals; component b: 1 signal
390        put_symbol(&store, "JobState", "a/job.py", "class", &[]);
391        put_symbol(&store, "advance", "a/job.py", "method", &[]);
392        put_symbol(&store, "TaskState", "b/task.py", "class", &[]);
393        let graph = RealityGraph::load(&store).unwrap();
394        let flows = compile_lifecycles(&graph, &store).unwrap();
395        assert_eq!(flows.len(), 1);
396        assert_eq!(flows[0].name, "a-lifecycle");
397        assert_eq!(flows[0].steps[0].operation, "a");
398    }
399}