1use 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
17pub 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
53fn 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 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 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 is_transition_verb(&e.name)
78}
79
80fn 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
105fn 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 "state"
123 }
124}
125
126pub 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 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; }
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 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 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 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 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 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 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}