Skip to main content

runkon_flow/dsl/
validation.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use super::types::{
5    Condition, InputType, OnChildFail, ScriptNode, WorkflowDef, WorkflowNode, QUALITY_GATE_TYPE,
6};
7use crate::traits::item_provider::ItemProviderRegistry;
8
9// ---------------------------------------------------------------------------
10// Semantic validation
11// ---------------------------------------------------------------------------
12
13/// A single semantic validation error found during static analysis of a workflow.
14#[derive(Debug, Clone)]
15pub struct ValidationError {
16    pub message: String,
17    pub hint: Option<String>,
18}
19
20impl fmt::Display for ValidationError {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match &self.hint {
23            Some(h) => write!(f, "{} (hint: {h})", self.message),
24            None => write!(f, "{}", self.message),
25        }
26    }
27}
28
29/// The result of running `validate_workflow_semantics`.
30#[derive(Debug, Default)]
31pub struct ValidationReport {
32    pub errors: Vec<ValidationError>,
33    pub warnings: Vec<String>,
34}
35
36impl ValidationReport {
37    pub fn is_ok(&self) -> bool {
38        self.errors.is_empty()
39    }
40}
41
42/// Context supplied by the host to parameterise semantic validation.
43pub struct ValidationContext<'a> {
44    /// Registry of registered item providers — used to validate `foreach` nodes.
45    pub registry: &'a ItemProviderRegistry,
46    /// Valid target labels for workflows. Empty slice means target validation is skipped.
47    pub valid_targets: &'a [&'a str],
48}
49
50pub fn validate_workflow_semantics<F>(
51    def: &WorkflowDef,
52    loader: &F,
53    ctx: &ValidationContext<'_>,
54) -> ValidationReport
55where
56    F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
57{
58    let mut errors = Vec::new();
59    let mut warnings = Vec::new();
60    let mut produced: HashSet<String> = HashSet::new();
61
62    let bool_inputs: HashSet<String> = def
63        .inputs
64        .iter()
65        .filter(|i| i.input_type == InputType::Boolean)
66        .map(|i| i.name.clone())
67        .collect();
68
69    validate_nodes(
70        &def.body,
71        &mut produced,
72        &mut errors,
73        &mut warnings,
74        loader,
75        &bool_inputs,
76        ctx,
77    );
78
79    let mut always_produced = produced.clone();
80    validate_nodes(
81        &def.always,
82        &mut always_produced,
83        &mut errors,
84        &mut warnings,
85        loader,
86        &bool_inputs,
87        ctx,
88    );
89
90    if !ctx.valid_targets.is_empty() {
91        for target in &def.targets {
92            if !ctx.valid_targets.contains(&target.as_str()) {
93                errors.push(ValidationError {
94                    message: format!(
95                        "Unknown target '{}' in workflow '{}'. Valid targets: {}",
96                        target,
97                        def.name,
98                        ctx.valid_targets.join(", ")
99                    ),
100                    hint: Some(format!(
101                        "Change '{}' to one of: {}",
102                        target,
103                        ctx.valid_targets.join(", ")
104                    )),
105                });
106            }
107        }
108    }
109
110    ValidationReport { errors, warnings }
111}
112
113fn validate_nodes<F>(
114    nodes: &[WorkflowNode],
115    produced: &mut HashSet<String>,
116    errors: &mut Vec<ValidationError>,
117    warnings: &mut Vec<String>,
118    loader: &F,
119    bool_inputs: &HashSet<String>,
120    ctx: &ValidationContext<'_>,
121) where
122    F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
123{
124    for node in nodes {
125        match node {
126            WorkflowNode::Call(n) => {
127                produced.insert(n.agent.step_key());
128            }
129            WorkflowNode::CallWorkflow(n) => {
130                match loader(&n.workflow) {
131                    Ok(sub_def) => {
132                        for input_decl in &sub_def.inputs {
133                            if input_decl.required && !n.inputs.contains_key(&input_decl.name) {
134                                errors.push(ValidationError {
135                                    message: format!(
136                                        "Sub-workflow '{}' requires input '{}' but it was not provided at the call site",
137                                        n.workflow, input_decl.name
138                                    ),
139                                    hint: None,
140                                });
141                            }
142                        }
143
144                        for sub_node in &sub_def.body {
145                            for key in node_step_keys(sub_node) {
146                                produced.insert(key);
147                            }
148                        }
149                    }
150                    Err(e) => {
151                        errors.push(ValidationError {
152                            message: format!(
153                                "Sub-workflow '{}' could not be loaded: {}",
154                                n.workflow, e
155                            ),
156                            hint: None,
157                        });
158                    }
159                }
160                produced.insert(n.workflow.clone());
161            }
162            WorkflowNode::Parallel(n) => {
163                for (step_name, _marker) in n.call_if.values() {
164                    check_condition_reachable(step_name, produced, errors);
165                }
166                for call in &n.calls {
167                    produced.insert(call.step_key());
168                }
169            }
170            WorkflowNode::If(n) => {
171                validate_conditional_branch(
172                    &n.condition,
173                    &n.body,
174                    produced,
175                    errors,
176                    warnings,
177                    loader,
178                    bool_inputs,
179                    ctx,
180                );
181            }
182            WorkflowNode::Unless(n) => {
183                validate_conditional_branch(
184                    &n.condition,
185                    &n.body,
186                    produced,
187                    errors,
188                    warnings,
189                    loader,
190                    bool_inputs,
191                    ctx,
192                );
193            }
194            WorkflowNode::While(n) => {
195                check_condition_reachable(&n.step, produced, errors);
196                let mut body_produced = produced.clone();
197                validate_nodes(
198                    &n.body,
199                    &mut body_produced,
200                    errors,
201                    warnings,
202                    loader,
203                    bool_inputs,
204                    ctx,
205                );
206                produced.extend(body_produced);
207            }
208            WorkflowNode::DoWhile(n) => {
209                validate_nodes(
210                    &n.body,
211                    produced,
212                    errors,
213                    warnings,
214                    loader,
215                    bool_inputs,
216                    ctx,
217                );
218                check_condition_reachable(&n.step, produced, errors);
219            }
220            WorkflowNode::Do(n) => {
221                validate_nodes(
222                    &n.body,
223                    produced,
224                    errors,
225                    warnings,
226                    loader,
227                    bool_inputs,
228                    ctx,
229                );
230            }
231            WorkflowNode::Gate(n) => {
232                if n.gate_type == QUALITY_GATE_TYPE && n.quality_gate.is_none() {
233                    errors.push(ValidationError {
234                        message: format!(
235                            "Quality gate '{}' is missing required `source` and `threshold` fields",
236                            n.name
237                        ),
238                        hint: Some("Add `source = \"step_name\"` and `threshold = 70` to configure the quality gate".to_string()),
239                    });
240                }
241                if let Some(source) = n.quality_gate.as_ref().map(|qg| &qg.source) {
242                    if !produced.contains(source.as_str()) {
243                        errors.push(ValidationError {
244                            message: format!(
245                                "Quality gate '{}' references source step '{}' which has not been produced at this point in the workflow",
246                                n.name, source
247                            ),
248                            hint: Some(format!(
249                                "Ensure a call or script step named '{}' appears before this gate",
250                                source
251                            )),
252                        });
253                    }
254                }
255            }
256            WorkflowNode::Script(n) => {
257                produced.insert(n.name.clone());
258            }
259            WorkflowNode::Always(n) => {
260                validate_nodes(
261                    &n.body,
262                    produced,
263                    errors,
264                    warnings,
265                    loader,
266                    bool_inputs,
267                    ctx,
268                );
269            }
270            WorkflowNode::ForEach(n) => {
271                validate_foreach_node(n, errors, warnings, loader, ctx);
272                produced.insert(format!("foreach:{}", n.name));
273            }
274        }
275    }
276}
277
278fn validate_foreach_node<F>(
279    n: &super::types::ForEachNode,
280    errors: &mut Vec<ValidationError>,
281    warnings: &mut Vec<String>,
282    loader: &F,
283    ctx: &ValidationContext<'_>,
284) where
285    F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
286{
287    match loader(&n.workflow) {
288        Ok(child_def) => {
289            for input_decl in &child_def.inputs {
290                if input_decl.required && !n.inputs.contains_key(&input_decl.name) {
291                    errors.push(ValidationError {
292                        message: format!(
293                            "foreach '{}': child workflow '{}' requires input '{}' \
294                             but it is not in the inputs map",
295                            n.name, n.workflow, input_decl.name
296                        ),
297                        hint: Some(format!(
298                            "Add `{} = \"{{{{item.*}}}}\"` or a literal value to the inputs block",
299                            input_decl.name
300                        )),
301                    });
302                }
303            }
304        }
305        Err(e) => {
306            errors.push(ValidationError {
307                message: format!(
308                    "foreach '{}': child workflow '{}' could not be loaded: {}",
309                    n.name, n.workflow, e
310                ),
311                hint: None,
312            });
313        }
314    }
315
316    let provider = match ctx.registry.get(&n.over) {
317        Some(p) => p,
318        None => {
319            errors.push(ValidationError {
320                message: format!(
321                    "foreach '{}': unknown provider '{}' — no ItemProvider registered for this name",
322                    n.name, n.over
323                ),
324                hint: None,
325            });
326            return;
327        }
328    };
329
330    // Scope validation
331    if let Err(e) = provider.parse_scope(n.scope.as_ref()) {
332        errors.push(ValidationError {
333            message: format!("foreach '{}': {e}", n.name),
334            hint: None,
335        });
336    }
337
338    // Scope warnings (e.g. worktrees with no scope falls back to context)
339    for w in provider.scope_warnings(n.scope.as_ref()) {
340        warnings.push(format!("foreach '{}': {w}", n.name));
341    }
342
343    // Ordered check
344    if n.ordered && !provider.supports_ordered() {
345        let ordered_names: Vec<String> = ctx
346            .registry
347            .iter()
348            .filter(|p| p.supports_ordered())
349            .map(|p| p.name().to_string())
350            .collect();
351        let hint = if ordered_names.is_empty() {
352            "Remove `ordered = true`".to_string()
353        } else {
354            format!(
355                "Remove `ordered = true` or change `over` to one of: {}",
356                ordered_names.join(", ")
357            )
358        };
359        errors.push(ValidationError {
360            message: format!(
361                "foreach '{}': ordered = true is not supported by provider '{}'",
362                n.name, n.over
363            ),
364            hint: Some(hint),
365        });
366    }
367
368    if n.on_child_fail == OnChildFail::SkipDependents && !n.ordered {
369        errors.push(ValidationError {
370            message: format!(
371                "foreach '{}': on_child_fail = skip_dependents has no effect without ordered = true",
372                n.name
373            ),
374            hint: Some(
375                "Add `ordered = true` or change on_child_fail to `continue` or `halt`".to_string(),
376            ),
377        });
378    }
379
380    // Filter requirements
381    if provider.requires_filter() && n.filter.is_empty() {
382        errors.push(ValidationError {
383            message: format!(
384                "foreach '{}': `filter` is required when over = {}",
385                n.name, n.over
386            ),
387            hint: Some(
388                "Add `filter = { status = \"failed\" }` (or another terminal status)".to_string(),
389            ),
390        });
391    }
392
393    if !n.filter.is_empty() {
394        if let Err(e) = provider.validate_filter(&n.filter) {
395            errors.push(ValidationError {
396                message: format!("foreach '{}': {e}", n.name),
397                hint: None,
398            });
399        }
400    }
401}
402
403fn node_step_keys(node: &WorkflowNode) -> Vec<String> {
404    match node {
405        WorkflowNode::Call(n) => vec![n.agent.step_key()],
406        WorkflowNode::CallWorkflow(n) => vec![n.workflow.clone()],
407        WorkflowNode::Script(n) => vec![n.name.clone()],
408        WorkflowNode::Parallel(n) => n.calls.iter().map(|c| c.step_key()).collect(),
409        WorkflowNode::ForEach(n) => vec![format!("foreach:{}", n.name)],
410        _ => vec![],
411    }
412}
413
414fn check_condition_reachable(
415    step: &str,
416    produced: &HashSet<String>,
417    errors: &mut Vec<ValidationError>,
418) {
419    if !produced.contains(step) {
420        errors.push(ValidationError {
421            message: format!(
422                "Condition references step '{}' which has not been produced at this point in the workflow",
423                step
424            ),
425            hint: None,
426        });
427    }
428}
429
430fn check_bool_input_declared(
431    input: &str,
432    bool_inputs: &HashSet<String>,
433    errors: &mut Vec<ValidationError>,
434) {
435    if !bool_inputs.contains(input) {
436        errors.push(ValidationError {
437            message: format!(
438                "Condition references '{}' which is not a declared boolean input",
439                input
440            ),
441            hint: Some(format!(
442                "Declare it in the workflow inputs block: `{} boolean`",
443                input
444            )),
445        });
446    }
447}
448
449#[allow(clippy::too_many_arguments)]
450fn validate_conditional_branch<F>(
451    condition: &Condition,
452    body: &[WorkflowNode],
453    produced: &mut HashSet<String>,
454    errors: &mut Vec<ValidationError>,
455    warnings: &mut Vec<String>,
456    loader: &F,
457    bool_inputs: &HashSet<String>,
458    ctx: &ValidationContext<'_>,
459) where
460    F: Fn(&str) -> std::result::Result<WorkflowDef, String>,
461{
462    match condition {
463        Condition::StepMarker { step, .. } => {
464            check_condition_reachable(step, produced, errors);
465        }
466        Condition::BoolInput { input } => {
467            check_bool_input_declared(input, bool_inputs, errors);
468        }
469    }
470    let mut branch_produced = produced.clone();
471    validate_nodes(
472        body,
473        &mut branch_produced,
474        errors,
475        warnings,
476        loader,
477        bool_inputs,
478        ctx,
479    );
480    produced.extend(branch_produced);
481}
482
483// ---------------------------------------------------------------------------
484// Script step validation
485// ---------------------------------------------------------------------------
486
487pub fn validate_script_steps<F>(def: &WorkflowDef, path_resolver: &F) -> Vec<ValidationError>
488where
489    F: Fn(&str) -> Result<std::path::PathBuf, String>,
490{
491    let mut errors = Vec::new();
492    let nodes: Vec<&ScriptNode> = collect_script_nodes(&def.body)
493        .into_iter()
494        .chain(collect_script_nodes(&def.always))
495        .collect();
496
497    for node in nodes {
498        let run = &node.run;
499
500        if run.contains("{{") {
501            continue;
502        }
503
504        match path_resolver(run) {
505            Err(searched) => {
506                errors.push(ValidationError {
507                    message: format!(
508                        "Script step '{}': '{}' not found. Searched: {}",
509                        node.name, run, searched
510                    ),
511                    hint: None,
512                });
513            }
514            Ok(resolved) => {
515                #[cfg(unix)]
516                if let Some(err) = check_script_unix_permissions(&node.name, &resolved) {
517                    errors.push(err);
518                }
519                #[cfg(not(unix))]
520                {
521                    let _ = resolved;
522                }
523            }
524        }
525    }
526
527    errors
528}
529
530#[cfg(unix)]
531fn check_script_unix_permissions(
532    step_name: &str,
533    resolved: &std::path::Path,
534) -> Option<ValidationError> {
535    use std::os::unix::fs::PermissionsExt;
536    match std::fs::metadata(resolved) {
537        Err(e) => Some(ValidationError {
538            message: format!(
539                "Script step '{}': could not read metadata for '{}': {}",
540                step_name,
541                resolved.display(),
542                e,
543            ),
544            hint: None,
545        }),
546        Ok(meta) => {
547            let mode = meta.permissions().mode();
548            if mode & 0o111 == 0 {
549                Some(ValidationError {
550                    message: format!(
551                        "Script step '{}': '{}' is not executable (mode {:04o})",
552                        step_name,
553                        resolved.display(),
554                        mode & 0o777,
555                    ),
556                    hint: Some(format!("Run: chmod +x {}", resolved.display())),
557                })
558            } else {
559                None
560            }
561        }
562    }
563}
564
565fn collect_script_nodes(nodes: &[WorkflowNode]) -> Vec<&ScriptNode> {
566    let mut out = Vec::new();
567    for node in nodes {
568        match node {
569            WorkflowNode::Script(s) => out.push(s),
570            WorkflowNode::If(n) => out.extend(collect_script_nodes(&n.body)),
571            WorkflowNode::Unless(n) => out.extend(collect_script_nodes(&n.body)),
572            WorkflowNode::While(n) => out.extend(collect_script_nodes(&n.body)),
573            WorkflowNode::DoWhile(n) => out.extend(collect_script_nodes(&n.body)),
574            WorkflowNode::Do(n) => out.extend(collect_script_nodes(&n.body)),
575            WorkflowNode::Always(n) => out.extend(collect_script_nodes(&n.body)),
576            WorkflowNode::Call(_)
577            | WorkflowNode::CallWorkflow(_)
578            | WorkflowNode::Gate(_)
579            | WorkflowNode::Parallel(_)
580            | WorkflowNode::ForEach(_) => {}
581        }
582    }
583    out
584}
585
586#[cfg(test)]
587mod tests {
588    use std::any::Any;
589    use std::collections::HashMap;
590
591    use super::{validate_script_steps, validate_workflow_semantics, ValidationContext};
592    use crate::dsl::parse_workflow_str;
593    use crate::engine_error::EngineError;
594    use crate::traits::item_provider::{
595        FanOutItem, ItemProvider, ItemProviderRegistry, ProviderInfo,
596    };
597    use crate::traits::run_context::RunContext;
598
599    fn no_loader(name: &str) -> Result<crate::dsl::WorkflowDef, String> {
600        Err(format!("sub-workflow '{}' not found", name))
601    }
602
603    fn always_resolve_ok(run: &str) -> Result<std::path::PathBuf, String> {
604        Ok(std::path::PathBuf::from(run))
605    }
606
607    fn always_resolve_err(run: &str) -> Result<std::path::PathBuf, String> {
608        Err(format!("not found: {run}"))
609    }
610
611    const CONDUCTOR_TARGETS: &[&str] = &["worktree", "ticket", "repo", "pr", "workflow_run"];
612
613    fn empty_ctx(registry: &ItemProviderRegistry) -> ValidationContext<'_> {
614        ValidationContext {
615            registry,
616            valid_targets: CONDUCTOR_TARGETS,
617        }
618    }
619
620    // ---- validate_workflow_semantics ----
621
622    #[test]
623    fn valid_simple_workflow_has_no_errors() {
624        let src = r#"
625workflow simple {
626    call my_agent
627}
628"#;
629        let def = parse_workflow_str(src, "test.wf").unwrap();
630        let registry = ItemProviderRegistry::new();
631        let ctx = empty_ctx(&registry);
632        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
633        assert!(
634            report.is_ok(),
635            "expected no errors, got: {:?}",
636            report.errors
637        );
638    }
639
640    #[test]
641    fn if_condition_referencing_unknown_step_is_an_error() {
642        let src = r#"
643workflow wf {
644    if unknown_step.done {
645        call another_agent
646    }
647}
648"#;
649        let def = parse_workflow_str(src, "test.wf").unwrap();
650        let registry = ItemProviderRegistry::new();
651        let ctx = empty_ctx(&registry);
652        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
653        assert!(
654            !report.is_ok(),
655            "expected validation error for unknown step reference"
656        );
657        assert!(
658            report
659                .errors
660                .iter()
661                .any(|e| e.message.contains("unknown_step")),
662            "error should mention the step name; errors: {:?}",
663            report.errors
664        );
665    }
666
667    #[test]
668    fn if_condition_after_producing_step_is_ok() {
669        let src = r#"
670workflow wf {
671    call step1
672    if step1.done {
673        call step2
674    }
675}
676"#;
677        let def = parse_workflow_str(src, "test.wf").unwrap();
678        let registry = ItemProviderRegistry::new();
679        let ctx = empty_ctx(&registry);
680        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
681        assert!(
682            report.is_ok(),
683            "step1 is produced before the if, so no error expected; got: {:?}",
684            report.errors
685        );
686    }
687
688    #[test]
689    fn bool_input_in_if_condition_without_declaration_is_an_error() {
690        let src = r#"
691workflow wf {
692    if undeclared_flag {
693        call agent
694    }
695}
696"#;
697        let def = parse_workflow_str(src, "test.wf").unwrap();
698        let registry = ItemProviderRegistry::new();
699        let ctx = empty_ctx(&registry);
700        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
701        assert!(
702            !report.is_ok(),
703            "undeclared bool input should be flagged as an error"
704        );
705        assert!(
706            report
707                .errors
708                .iter()
709                .any(|e| e.message.contains("undeclared_flag")),
710            "error should mention the input name; errors: {:?}",
711            report.errors
712        );
713    }
714
715    #[test]
716    fn bool_input_declared_in_inputs_block_is_ok() {
717        let src = r#"
718workflow wf {
719    inputs {
720        run_extra boolean
721    }
722    if run_extra {
723        call optional_agent
724    }
725}
726"#;
727        let def = parse_workflow_str(src, "test.wf").unwrap();
728        let registry = ItemProviderRegistry::new();
729        let ctx = empty_ctx(&registry);
730        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
731        assert!(
732            report.is_ok(),
733            "declared boolean input in if condition should be valid; got: {:?}",
734            report.errors
735        );
736    }
737
738    #[test]
739    fn invalid_target_produces_error() {
740        let src = r#"
741workflow wf {
742    meta {
743        targets = ["invalid_target"]
744    }
745    call agent
746}
747"#;
748        let def = parse_workflow_str(src, "test.wf").unwrap();
749        let registry = ItemProviderRegistry::new();
750        let ctx = empty_ctx(&registry);
751        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
752        assert!(!report.is_ok(), "invalid target should produce an error");
753        assert!(
754            report
755                .errors
756                .iter()
757                .any(|e| e.message.contains("invalid_target")),
758            "error should mention the bad target; errors: {:?}",
759            report.errors
760        );
761    }
762
763    #[test]
764    fn workflow_with_no_body_is_valid() {
765        let src = r#"
766workflow empty {
767}
768"#;
769        let def = parse_workflow_str(src, "test.wf").unwrap();
770        let registry = ItemProviderRegistry::new();
771        let ctx = empty_ctx(&registry);
772        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
773        assert!(
774            report.is_ok(),
775            "empty workflow body should be valid; got: {:?}",
776            report.errors
777        );
778    }
779
780    // ---- validate_script_steps ----
781
782    #[test]
783    fn script_with_template_variable_skips_path_check() {
784        let src = r#"
785workflow wf {
786    script my_script {
787        run = "{{scripts_dir}}/check.sh"
788    }
789}
790"#;
791        let def = parse_workflow_str(src, "test.wf").unwrap();
792        // Even though the resolver would fail, template variables bypass path checking
793        let errors = validate_script_steps(&def, &always_resolve_err);
794        assert!(
795            errors.is_empty(),
796            "script with template variable should skip path check; got: {:?}",
797            errors
798        );
799    }
800
801    #[test]
802    fn script_path_not_found_produces_error() {
803        let src = r#"
804workflow wf {
805    script lint {
806        run = "/nonexistent/script.sh"
807    }
808}
809"#;
810        let def = parse_workflow_str(src, "test.wf").unwrap();
811        let errors = validate_script_steps(&def, &always_resolve_err);
812        assert!(
813            !errors.is_empty(),
814            "missing script path should produce a validation error"
815        );
816        assert!(
817            errors.iter().any(|e| e.message.contains("lint")),
818            "error should mention the step name; errors: {:?}",
819            errors
820        );
821    }
822
823    #[test]
824    fn script_path_resolved_ok_has_no_errors() {
825        let src = r#"
826workflow wf {
827    script check {
828        run = "some_script.sh"
829    }
830}
831"#;
832        let def = parse_workflow_str(src, "test.wf").unwrap();
833        // On non-unix we can't check permissions, so resolution success → no errors
834        #[cfg(not(unix))]
835        {
836            let errors = validate_script_steps(&def, &always_resolve_ok);
837            assert!(
838                errors.is_empty(),
839                "successfully resolved script should produce no errors on non-unix; got: {:?}",
840                errors
841            );
842        }
843        // On unix the file must actually exist and be executable, so just verify
844        // we don't panic and the error (if any) mentions the step.
845        #[cfg(unix)]
846        {
847            let errors = validate_script_steps(&def, &always_resolve_ok);
848            // The path we resolve (/some_script.sh) likely doesn't exist — the
849            // important thing is that the function ran without panicking.
850            let _ = errors;
851        }
852    }
853
854    #[test]
855    fn workflow_with_multiple_scripts_checks_each() {
856        let src = r#"
857workflow wf {
858    script step_a {
859        run = "/missing/a.sh"
860    }
861    script step_b {
862        run = "/missing/b.sh"
863    }
864}
865"#;
866        let def = parse_workflow_str(src, "test.wf").unwrap();
867        let errors = validate_script_steps(&def, &always_resolve_err);
868        assert_eq!(
869            errors.len(),
870            2,
871            "each unresolvable script should generate one error; got: {:?}",
872            errors
873        );
874    }
875
876    // ---- foreach validation ----
877
878    struct BasicProvider {
879        provider_name: &'static str,
880        ordered: bool,
881    }
882
883    impl ItemProvider for BasicProvider {
884        fn name(&self) -> &str {
885            self.provider_name
886        }
887
888        fn supports_ordered(&self) -> bool {
889            self.ordered
890        }
891
892        fn items(
893            &self,
894            _ctx: &dyn RunContext,
895            _info: &ProviderInfo,
896            _scope: Option<&dyn Any>,
897            _filter: &HashMap<String, String>,
898        ) -> Result<Vec<FanOutItem>, EngineError> {
899            Ok(vec![])
900        }
901    }
902
903    #[test]
904    fn foreach_unregistered_provider_mentions_provider_name() {
905        let src = r#"
906workflow wf {
907    foreach fan {
908        over = unknown_provider
909        max_parallel = 2
910        workflow = child_wf
911    }
912}
913"#;
914        let def = parse_workflow_str(src, "test.wf").unwrap();
915        let registry = ItemProviderRegistry::new();
916        let ctx = empty_ctx(&registry);
917        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
918        assert!(!report.is_ok(), "unregistered provider should fail");
919        assert!(
920            report
921                .errors
922                .iter()
923                .any(|e| e.message.contains("unknown_provider")),
924            "error should mention provider name; errors: {:?}",
925            report.errors
926        );
927    }
928
929    #[test]
930    fn foreach_ordered_with_unsupporting_provider_is_error() {
931        let src = r#"
932workflow wf {
933    foreach fan {
934        over = simple_provider
935        max_parallel = 2
936        workflow = child_wf
937        ordered = true
938    }
939}
940"#;
941        let def = parse_workflow_str(src, "test.wf").unwrap();
942        let mut registry = ItemProviderRegistry::new();
943        registry.register(BasicProvider {
944            provider_name: "simple_provider",
945            ordered: false,
946        });
947        let ctx = empty_ctx(&registry);
948        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
949        assert!(
950            !report.is_ok(),
951            "ordered=true with unsupporting provider should fail"
952        );
953        assert!(
954            report.errors.iter().any(|e| e.message.contains("ordered")),
955            "error should mention ordered; errors: {:?}",
956            report.errors
957        );
958    }
959
960    // ---- quality gate source step validation ----
961
962    #[test]
963    fn quality_gate_source_step_exists_no_error() {
964        let src = r#"
965workflow wf {
966    call analyzer
967    gate quality_gate {
968        source = analyzer
969        threshold = 70
970    }
971}
972"#;
973        let def = parse_workflow_str(src, "test.wf").unwrap();
974        let registry = ItemProviderRegistry::new();
975        let ctx = empty_ctx(&registry);
976        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
977        assert!(
978            report.is_ok(),
979            "quality gate referencing a prior step should have no errors; got: {:?}",
980            report.errors
981        );
982    }
983
984    #[test]
985    fn quality_gate_source_step_not_in_workflow_is_error() {
986        let src = r#"
987workflow wf {
988    gate quality_gate {
989        source = nonexistent_step
990        threshold = 70
991    }
992}
993"#;
994        let def = parse_workflow_str(src, "test.wf").unwrap();
995        let registry = ItemProviderRegistry::new();
996        let ctx = empty_ctx(&registry);
997        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
998        assert!(
999            !report.is_ok(),
1000            "quality gate with missing source step should fail"
1001        );
1002        assert!(
1003            report
1004                .errors
1005                .iter()
1006                .any(|e| e.message.contains("nonexistent_step")),
1007            "error should mention the missing step; errors: {:?}",
1008            report.errors
1009        );
1010    }
1011
1012    // ---- deeply nested workflow produce-set tracking ----
1013
1014    #[test]
1015    fn deeply_nested_workflow_tracks_produce_set_correctly() {
1016        let src = r#"
1017workflow wf {
1018    call outer_step
1019    if outer_step.done {
1020        while outer_step.done {
1021            max_iterations = 3
1022            call inner_step
1023        }
1024        if inner_step.ready {
1025            call leaf_step
1026        }
1027    }
1028}
1029"#;
1030        let def = parse_workflow_str(src, "test.wf").unwrap();
1031        let registry = ItemProviderRegistry::new();
1032        let ctx = empty_ctx(&registry);
1033        let report = validate_workflow_semantics(&def, &no_loader, &ctx);
1034        assert!(
1035            report.is_ok(),
1036            "deeply nested workflow with valid produce-set should have no errors; got: {:?}",
1037            report.errors
1038        );
1039    }
1040}