Skip to main content

orchestrator_config/config/
step_conventions.rs

1//! Data-driven step convention registry.
2//!
3//! Instead of hardcoding SDLC step defaults in Rust match arms, this module
4//! loads conventions from a compiled-in YAML file and exposes them through a
5//! singleton [`CONVENTIONS`] registry.  The framework accepts *any* step ID;
6//! if the ID has no entry here and no explicit configuration, the universal
7//! fallback rule applies: `required_capability = step_id`.
8
9use super::{CaptureDecl, CaptureSource, PostAction, StepScope};
10use serde::Deserialize;
11use std::collections::HashMap;
12use std::sync::LazyLock;
13
14/// Singleton convention registry — immutable compile-time data.
15pub static CONVENTIONS: LazyLock<StepConventionRegistry> =
16    LazyLock::new(StepConventionRegistry::builtin);
17
18// ── Convention types ─────────────────────────────────────────────────
19
20/// Defaults for a well-known step ID, loaded from convention YAML.
21#[derive(Debug, Clone, Default)]
22pub struct StepConvention {
23    /// Builtin name (only for true framework builtins with Rust impls).
24    pub builtin: Option<String>,
25    /// Default execution scope.
26    pub scope: Option<StepScope>,
27    /// Default is_guard flag.
28    pub is_guard: bool,
29    /// Default collect_artifacts flag.
30    pub collect_artifacts: bool,
31    /// Default captures to inject when user hasn't configured them.
32    pub captures: Vec<CaptureDecl>,
33    /// Default post_actions to inject when user hasn't configured them.
34    pub post_actions: Vec<PostAction>,
35}
36
37/// Registry of step conventions, keyed by step ID.
38#[derive(Debug, Default)]
39pub struct StepConventionRegistry {
40    conventions: HashMap<String, StepConvention>,
41}
42
43impl StepConventionRegistry {
44    /// Build the registry from the compiled-in SDLC conventions YAML.
45    fn builtin() -> Self {
46        let yaml = include_str!("sdlc_conventions.yaml");
47        let raw: RawConventions = match serde_yaml::from_str(yaml) {
48            Ok(v) => v,
49            // Compiled-in YAML — parse failure means a build-time bug.
50            Err(_) => return Self::default(),
51        };
52
53        let mut conventions = HashMap::new();
54        for (id, entry) in raw.steps {
55            let scope = entry.scope.as_deref().map(|s| match s {
56                "item" => StepScope::Item,
57                _ => StepScope::Task,
58            });
59
60            let captures = entry
61                .captures
62                .into_iter()
63                .filter_map(|c| {
64                    let source = match c.source.as_str() {
65                        "failed_flag" => CaptureSource::FailedFlag,
66                        "success_flag" => CaptureSource::SuccessFlag,
67                        "stdout" => CaptureSource::Stdout,
68                        "stderr" => CaptureSource::Stderr,
69                        "exit_code" => CaptureSource::ExitCode,
70                        _ => return None,
71                    };
72                    Some(CaptureDecl {
73                        var: c.var,
74                        source,
75                        json_path: None,
76                    })
77                })
78                .collect();
79
80            let post_actions = entry
81                .post_actions
82                .into_iter()
83                .filter_map(|a| match a.as_str() {
84                    "create_ticket" => Some(PostAction::CreateTicket),
85                    "scan_tickets" => Some(PostAction::ScanTickets),
86                    _ => None,
87                })
88                .collect();
89
90            conventions.insert(
91                id,
92                StepConvention {
93                    builtin: entry.builtin,
94                    scope,
95                    is_guard: entry.is_guard,
96                    collect_artifacts: entry.collect_artifacts,
97                    captures,
98                    post_actions,
99                },
100            );
101        }
102
103        Self { conventions }
104    }
105
106    /// Look up a convention entry by step ID.
107    pub fn lookup(&self, step_id: &str) -> Option<&StepConvention> {
108        self.conventions.get(step_id)
109    }
110
111    /// Returns the default scope for a step ID.
112    /// Falls back to `StepScope::Task` when no convention entry exists.
113    pub fn default_scope(&self, step_id: &str) -> StepScope {
114        self.conventions
115            .get(step_id)
116            .and_then(|c| c.scope)
117            .unwrap_or(StepScope::Task)
118    }
119
120    /// Returns the builtin name for a step ID, if it maps to a framework builtin.
121    pub fn builtin_name(&self, step_id: &str) -> Option<String> {
122        self.conventions
123            .get(step_id)
124            .and_then(|c| c.builtin.clone())
125    }
126
127    /// Returns `true` when the step ID maps to a framework builtin with a Rust impl.
128    pub fn is_known_builtin(&self, step_id: &str) -> bool {
129        self.conventions
130            .get(step_id)
131            .and_then(|c| c.builtin.as_ref())
132            .is_some()
133    }
134}
135
136// ── Raw serde types for the YAML file ────────────────────────────────
137
138#[derive(Deserialize)]
139struct RawConventions {
140    steps: HashMap<String, RawStepConvention>,
141}
142
143#[derive(Deserialize)]
144struct RawStepConvention {
145    #[serde(default)]
146    builtin: Option<String>,
147    #[serde(default)]
148    scope: Option<String>,
149    #[serde(default)]
150    is_guard: bool,
151    #[serde(default)]
152    collect_artifacts: bool,
153    #[serde(default)]
154    captures: Vec<RawCapture>,
155    #[serde(default)]
156    post_actions: Vec<String>,
157}
158
159#[derive(Deserialize)]
160struct RawCapture {
161    var: String,
162    source: String,
163}
164
165// ── Tests ────────────────────────────────────────────────────────────
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn builtin_conventions_parse() {
173        let registry = StepConventionRegistry::builtin();
174        // All 22 well-known steps are present
175        assert!(registry.lookup("init_once").is_some());
176        assert!(registry.lookup("plan").is_some());
177        assert!(registry.lookup("qa").is_some());
178        assert!(registry.lookup("ticket_scan").is_some());
179        assert!(registry.lookup("fix").is_some());
180        assert!(registry.lookup("retest").is_some());
181        assert!(registry.lookup("loop_guard").is_some());
182        assert!(registry.lookup("build").is_some());
183        assert!(registry.lookup("test").is_some());
184        assert!(registry.lookup("lint").is_some());
185        assert!(registry.lookup("implement").is_some());
186        assert!(registry.lookup("review").is_some());
187        assert!(registry.lookup("git_ops").is_some());
188        assert!(registry.lookup("qa_doc_gen").is_some());
189        assert!(registry.lookup("qa_testing").is_some());
190        assert!(registry.lookup("ticket_fix").is_some());
191        assert!(registry.lookup("doc_governance").is_some());
192        assert!(registry.lookup("align_tests").is_some());
193        assert!(registry.lookup("self_test").is_some());
194        assert!(registry.lookup("self_restart").is_some());
195        assert!(registry.lookup("smoke_chain").is_some());
196        assert!(registry.lookup("evaluate").is_some());
197        assert!(registry.lookup("item_select").is_some());
198    }
199
200    #[test]
201    fn framework_builtins_detected() {
202        let registry = StepConventionRegistry::builtin();
203        for name in &[
204            "init_once",
205            "loop_guard",
206            "ticket_scan",
207            "self_test",
208            "self_restart",
209            "item_select",
210        ] {
211            assert!(
212                registry.is_known_builtin(name),
213                "{name} should be a known builtin"
214            );
215        }
216        // SDLC agent steps are NOT builtins
217        for name in &["plan", "qa", "fix", "qa_doc_gen", "ticket_fix"] {
218            assert!(
219                !registry.is_known_builtin(name),
220                "{name} should NOT be a builtin"
221            );
222        }
223    }
224
225    #[test]
226    fn scope_defaults() {
227        let registry = StepConventionRegistry::builtin();
228        assert_eq!(registry.default_scope("plan"), StepScope::Task);
229        assert_eq!(registry.default_scope("qa"), StepScope::Item);
230        assert_eq!(registry.default_scope("qa_testing"), StepScope::Item);
231        assert_eq!(registry.default_scope("ticket_fix"), StepScope::Item);
232        assert_eq!(registry.default_scope("fix"), StepScope::Item);
233        assert_eq!(registry.default_scope("retest"), StepScope::Item);
234        assert_eq!(registry.default_scope("implement"), StepScope::Task);
235        // Unknown step ID falls back to Task
236        assert_eq!(registry.default_scope("my_custom_step"), StepScope::Task);
237    }
238
239    #[test]
240    fn qa_step_has_captures_and_post_actions() {
241        let registry = StepConventionRegistry::builtin();
242        let qa = registry.lookup("qa").unwrap();
243        assert!(qa.collect_artifacts);
244        assert_eq!(qa.captures.len(), 1);
245        assert_eq!(qa.captures[0].var, "qa_failed");
246        assert_eq!(qa.captures[0].source, CaptureSource::FailedFlag);
247        assert_eq!(qa.post_actions.len(), 1);
248        assert_eq!(qa.post_actions[0], PostAction::CreateTicket);
249    }
250
251    #[test]
252    fn fix_step_has_captures() {
253        let registry = StepConventionRegistry::builtin();
254        let fix = registry.lookup("fix").unwrap();
255        assert!(!fix.collect_artifacts);
256        assert_eq!(fix.captures.len(), 1);
257        assert_eq!(fix.captures[0].var, "fix_success");
258        assert_eq!(fix.captures[0].source, CaptureSource::SuccessFlag);
259        assert!(fix.post_actions.is_empty());
260    }
261
262    #[test]
263    fn unknown_step_returns_none() {
264        let registry = StepConventionRegistry::builtin();
265        assert!(registry.lookup("my_custom_deploy").is_none());
266    }
267}