Skip to main content

kaish_kernel/validator/
walker.rs

1//! AST walker for pre-execution validation.
2
3use std::collections::HashMap;
4
5use crate::ast::{
6    Arg, Assignment, CaseBranch, CaseStmt, Command, Expr, ForLoop, IfStmt, Pipeline, Program,
7    Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop, Value,
8};
9use crate::tools::{ToolArgs, ToolRegistry};
10
11use super::issue::{IssueCode, ValidationIssue};
12use super::scope_tracker::ScopeTracker;
13
14/// AST validator that checks for issues before execution.
15pub struct Validator<'a> {
16    /// Reference to the tool registry.
17    registry: &'a ToolRegistry,
18    /// User-defined tools.
19    user_tools: &'a HashMap<String, ToolDef>,
20    /// Variable scope tracker.
21    scope: ScopeTracker,
22    /// Current loop nesting depth.
23    loop_depth: usize,
24    /// Current function nesting depth.
25    function_depth: usize,
26    /// Collected validation issues.
27    issues: Vec<ValidationIssue>,
28}
29
30impl<'a> Validator<'a> {
31    /// Create a new validator.
32    pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
33        Self {
34            registry,
35            user_tools,
36            scope: ScopeTracker::new(),
37            loop_depth: 0,
38            function_depth: 0,
39            issues: Vec::new(),
40        }
41    }
42
43    /// Validate a program and return all issues found.
44    pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
45        for stmt in &program.statements {
46            self.validate_stmt(stmt);
47        }
48        self.issues
49    }
50
51    /// Validate a single statement.
52    fn validate_stmt(&mut self, stmt: &Stmt) {
53        match stmt {
54            Stmt::Assignment(assign) => self.validate_assignment(assign),
55            Stmt::Command(cmd) => self.validate_command(cmd),
56            Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
57            Stmt::If(if_stmt) => self.validate_if(if_stmt),
58            Stmt::For(for_loop) => self.validate_for(for_loop),
59            Stmt::While(while_loop) => self.validate_while(while_loop),
60            Stmt::Case(case_stmt) => self.validate_case(case_stmt),
61            Stmt::Break(levels) => self.validate_break(*levels),
62            Stmt::Continue(levels) => self.validate_continue(*levels),
63            Stmt::Return(expr) => self.validate_return(expr.as_deref()),
64            Stmt::Exit(expr) => {
65                if let Some(e) = expr {
66                    self.validate_expr(e);
67                }
68            }
69            Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
70            Stmt::Test(test_expr) => self.validate_test(test_expr),
71            Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
72                self.validate_stmt(left);
73                self.validate_stmt(right);
74            }
75            Stmt::Empty => {}
76        }
77    }
78
79    /// Validate an assignment statement.
80    fn validate_assignment(&mut self, assign: &Assignment) {
81        // Validate the value expression
82        self.validate_expr(&assign.value);
83        // Bind the variable name in scope
84        self.scope.bind(&assign.name);
85    }
86
87    /// Validate a command invocation.
88    fn validate_command(&mut self, cmd: &Command) {
89        // Skip source/. commands - they're dynamic
90        if cmd.name == "source" || cmd.name == "." {
91            return;
92        }
93
94        // Skip dynamic command names (variable expansions)
95        if !is_static_command_name(&cmd.name) {
96            return;
97        }
98
99        // Check if command exists
100        let is_builtin = self.registry.contains(&cmd.name);
101        let is_user_tool = self.user_tools.contains_key(&cmd.name);
102        let is_special = is_special_command(&cmd.name);
103
104        if !is_builtin && !is_user_tool && !is_special {
105            // Warning only - command might be a script in PATH or external tool
106            self.issues.push(ValidationIssue::warning(
107                IssueCode::UndefinedCommand,
108                format!("command '{}' not found in builtin registry", cmd.name),
109            ).with_suggestion("this may be a script in PATH or external command"));
110        }
111
112        // Validate arguments expressions
113        for arg in &cmd.args {
114            self.validate_arg(arg);
115        }
116
117        // If we have a schema, validate args against it
118        if let Some(tool) = self.registry.get(&cmd.name) {
119            let tool_args = build_tool_args_for_validation(&cmd.args);
120            let tool_issues = tool.validate(&tool_args);
121            self.issues.extend(tool_issues);
122        } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
123            // Validate against user-defined tool parameters
124            self.validate_user_tool_args(user_tool, &cmd.args);
125        }
126
127        // Validate redirects
128        for redirect in &cmd.redirects {
129            self.validate_expr(&redirect.target);
130        }
131    }
132
133    /// Validate a command argument.
134    fn validate_arg(&mut self, arg: &Arg) {
135        match arg {
136            Arg::Positional(expr) => self.validate_expr(expr),
137            Arg::Named { value, .. } => self.validate_expr(value),
138            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
139        }
140    }
141
142    /// Validate a pipeline.
143    fn validate_pipeline(&mut self, pipe: &Pipeline) {
144        // Check for scatter without gather
145        let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
146        let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
147        if has_scatter && !has_gather {
148            self.issues.push(
149                ValidationIssue::error(
150                    IssueCode::ScatterWithoutGather,
151                    "scatter without gather — parallel results would be lost",
152                ).with_suggestion("add gather: ... | scatter | cmd | gather")
153            );
154        }
155
156        for cmd in &pipe.commands {
157            self.validate_command(cmd);
158        }
159    }
160
161    /// Validate an if statement.
162    fn validate_if(&mut self, if_stmt: &IfStmt) {
163        self.validate_expr(&if_stmt.condition);
164
165        self.scope.push_frame();
166        for stmt in &if_stmt.then_branch {
167            self.validate_stmt(stmt);
168        }
169        self.scope.pop_frame();
170
171        if let Some(else_branch) = &if_stmt.else_branch {
172            self.scope.push_frame();
173            for stmt in else_branch {
174                self.validate_stmt(stmt);
175            }
176            self.scope.pop_frame();
177        }
178    }
179
180    /// Validate a for loop.
181    fn validate_for(&mut self, for_loop: &ForLoop) {
182        // Validate item expressions and check for bare scalar variables
183        for item in &for_loop.items {
184            self.validate_expr(item);
185
186            // Detect `for i in $VAR` pattern - always a mistake in kaish
187            // since we don't do implicit word splitting
188            if self.is_bare_scalar_var(item) {
189                self.issues.push(
190                    ValidationIssue::error(
191                        IssueCode::ForLoopScalarVar,
192                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
193                    )
194                    .with_suggestion(concat!(
195                        "use one of:\n",
196                        "    for i in $(split \"$VAR\")      # split on whitespace\n",
197                        "    for i in $(split \"$VAR\" \":\")  # split on delimiter\n",
198                        "    for i in $(seq 1 10)          # iterate numbers\n",
199                        "    for i in $(glob \"*.rs\")       # iterate files",
200                    )),
201                );
202            }
203        }
204
205        self.loop_depth += 1;
206        self.scope.push_frame();
207
208        // Bind loop variable
209        self.scope.bind(&for_loop.variable);
210
211        for stmt in &for_loop.body {
212            self.validate_stmt(stmt);
213        }
214
215        self.scope.pop_frame();
216        self.loop_depth -= 1;
217    }
218
219    /// Extract glob pattern from unquoted literal expressions.
220    ///
221    /// Check if an expression is a bare scalar variable reference.
222    ///
223    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
224    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
225        match expr {
226            // Direct variable reference like $VAR or ${VAR}
227            Expr::VarRef(_) => true,
228            // Variable with default like ${VAR:-default} - also problematic
229            Expr::VarWithDefault { .. } => true,
230            // NOT a problem: command substitution like $(cmd) - returns structured data
231            Expr::CommandSubst(_) => false,
232            // NOT a problem: literals are fine
233            Expr::Literal(_) => false,
234            // NOT a problem: interpolated strings are a single value
235            Expr::Interpolated(_) => false,
236            // Everything else: not a bare scalar var
237            _ => false,
238        }
239    }
240
241    /// Validate a while loop.
242    fn validate_while(&mut self, while_loop: &WhileLoop) {
243        self.validate_expr(&while_loop.condition);
244
245        self.loop_depth += 1;
246        self.scope.push_frame();
247
248        for stmt in &while_loop.body {
249            self.validate_stmt(stmt);
250        }
251
252        self.scope.pop_frame();
253        self.loop_depth -= 1;
254    }
255
256    /// Validate a case statement.
257    fn validate_case(&mut self, case_stmt: &CaseStmt) {
258        self.validate_expr(&case_stmt.expr);
259
260        for branch in &case_stmt.branches {
261            self.validate_case_branch(branch);
262        }
263    }
264
265    /// Validate a case branch.
266    fn validate_case_branch(&mut self, branch: &CaseBranch) {
267        self.scope.push_frame();
268        for stmt in &branch.body {
269            self.validate_stmt(stmt);
270        }
271        self.scope.pop_frame();
272    }
273
274    /// Validate a break statement.
275    fn validate_break(&mut self, levels: Option<usize>) {
276        if self.loop_depth == 0 {
277            self.issues.push(ValidationIssue::error(
278                IssueCode::BreakOutsideLoop,
279                "break used outside of a loop",
280            ));
281        } else if let Some(n) = levels
282            && n > self.loop_depth {
283                self.issues.push(ValidationIssue::warning(
284                    IssueCode::BreakOutsideLoop,
285                    format!(
286                        "break {} exceeds loop nesting depth {}",
287                        n, self.loop_depth
288                    ),
289                ));
290            }
291    }
292
293    /// Validate a continue statement.
294    fn validate_continue(&mut self, levels: Option<usize>) {
295        if self.loop_depth == 0 {
296            self.issues.push(ValidationIssue::error(
297                IssueCode::BreakOutsideLoop,
298                "continue used outside of a loop",
299            ));
300        } else if let Some(n) = levels
301            && n > self.loop_depth {
302                self.issues.push(ValidationIssue::warning(
303                    IssueCode::BreakOutsideLoop,
304                    format!(
305                        "continue {} exceeds loop nesting depth {}",
306                        n, self.loop_depth
307                    ),
308                ));
309            }
310    }
311
312    /// Validate a return statement.
313    fn validate_return(&mut self, expr: Option<&Expr>) {
314        if let Some(e) = expr {
315            self.validate_expr(e);
316        }
317
318        if self.function_depth == 0 {
319            self.issues.push(ValidationIssue::error(
320                IssueCode::ReturnOutsideFunction,
321                "return used outside of a function",
322            ));
323        }
324    }
325
326    /// Validate a tool definition.
327    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
328        self.function_depth += 1;
329        self.scope.push_frame();
330
331        // Bind parameters
332        for param in &tool_def.params {
333            self.scope.bind(&param.name);
334            // Validate default expressions
335            if let Some(default) = &param.default {
336                self.validate_expr(default);
337            }
338        }
339
340        // Validate body
341        for stmt in &tool_def.body {
342            self.validate_stmt(stmt);
343        }
344
345        self.scope.pop_frame();
346        self.function_depth -= 1;
347    }
348
349    /// Validate a test expression.
350    fn validate_test(&mut self, test: &TestExpr) {
351        match test {
352            TestExpr::FileTest { path, .. } => self.validate_expr(path),
353            TestExpr::StringTest { value, .. } => self.validate_expr(value),
354            TestExpr::Comparison { left, right, .. } => {
355                self.validate_expr(left);
356                self.validate_expr(right);
357            }
358            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
359                self.validate_test(left);
360                self.validate_test(right);
361            }
362            TestExpr::Not { expr } => self.validate_test(expr),
363        }
364    }
365
366    /// Validate an expression.
367    fn validate_expr(&mut self, expr: &Expr) {
368        match expr {
369            Expr::Literal(_) => {}
370            Expr::VarRef(path) => self.validate_var_ref(path),
371            Expr::Interpolated(parts) => {
372                for part in parts {
373                    self.validate_string_part(part);
374                }
375            }
376            Expr::BinaryOp { left, right, .. } => {
377                self.validate_expr(left);
378                self.validate_expr(right);
379            }
380            Expr::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
381            Expr::Test(test) => self.validate_test(test),
382            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
383            Expr::VarLength(name) => self.check_var_defined(name),
384            Expr::VarWithDefault { name, .. } => {
385                // Don't warn - default handles undefined case
386                let _ = name;
387            }
388            Expr::Arithmetic(_) => {
389                // Arithmetic parsing is done at runtime
390            }
391            Expr::Command(cmd) => self.validate_command(cmd),
392            Expr::LastExitCode | Expr::CurrentPid => {}
393            Expr::GlobPattern(_) => {}
394        }
395    }
396
397    /// Validate a variable reference.
398    fn validate_var_ref(&mut self, path: &VarPath) {
399        if let Some(VarSegment::Field(name)) = path.segments.first() {
400            self.check_var_defined(name);
401        }
402    }
403
404    /// Validate a string interpolation part.
405    fn validate_string_part(&mut self, part: &StringPart) {
406        match part {
407            StringPart::Literal(_) => {}
408            StringPart::Var(path) => self.validate_var_ref(path),
409            StringPart::VarWithDefault { default, .. } => {
410                // Validate nested parts in the default value
411                for p in default {
412                    self.validate_string_part(p);
413                }
414            }
415            StringPart::VarLength(name) => self.check_var_defined(name),
416            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
417            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
418            StringPart::CommandSubst(pipeline) => self.validate_pipeline(pipeline),
419            StringPart::LastExitCode | StringPart::CurrentPid => {}
420        }
421    }
422
423    /// Check if a variable is defined and warn if not.
424    fn check_var_defined(&mut self, name: &str) {
425        // Skip underscore-prefixed vars (external/unchecked convention)
426        if ScopeTracker::should_skip_undefined_check(name) {
427            return;
428        }
429
430        if !self.scope.is_bound(name) {
431            self.issues.push(ValidationIssue::warning(
432                IssueCode::PossiblyUndefinedVariable,
433                format!("variable '{}' may be undefined", name),
434            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
435        }
436    }
437
438    /// Validate arguments against a user-defined tool's parameters.
439    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
440        let positional_count = args
441            .iter()
442            .filter(|a| matches!(a, Arg::Positional(_)))
443            .count();
444
445        let required_count = tool_def
446            .params
447            .iter()
448            .filter(|p| p.default.is_none())
449            .count();
450
451        if positional_count < required_count {
452            self.issues.push(ValidationIssue::error(
453                IssueCode::MissingRequiredArg,
454                format!(
455                    "'{}' requires {} arguments, got {}",
456                    tool_def.name, required_count, positional_count
457                ),
458            ));
459        }
460    }
461}
462
463/// Check if a command name is static (not a variable expansion).
464fn is_static_command_name(name: &str) -> bool {
465    !name.starts_with('$') && !name.contains("$(")
466}
467
468/// Check if a command is a special built-in that we don't validate.
469fn is_special_command(name: &str) -> bool {
470    matches!(
471        name,
472        "true" | "false" | ":" | "test" | "[" | "[[" | "readonly" | "local"
473    )
474}
475
476/// Build ToolArgs from AST Args for validation purposes.
477///
478/// This is a simplified version that doesn't evaluate expressions -
479/// it uses placeholder values since we only care about argument structure.
480pub fn build_tool_args_for_validation(args: &[Arg]) -> ToolArgs {
481    let mut tool_args = ToolArgs::new();
482
483    for arg in args {
484        match arg {
485            Arg::Positional(expr) => {
486                tool_args.positional.push(expr_to_placeholder(expr));
487            }
488            Arg::Named { key, value } => {
489                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
490            }
491            Arg::ShortFlag(flag) => {
492                tool_args.flags.insert(flag.clone());
493            }
494            Arg::LongFlag(flag) => {
495                tool_args.flags.insert(flag.clone());
496            }
497            Arg::DoubleDash => {}
498        }
499    }
500
501    tool_args
502}
503
504/// Convert an expression to a placeholder value for validation.
505///
506/// For literal values, return the actual value.
507/// For dynamic expressions (var refs, command subst), return a placeholder.
508fn expr_to_placeholder(expr: &Expr) -> Value {
509    match expr {
510        Expr::Literal(val) => val.clone(),
511        Expr::Interpolated(parts) if parts.len() == 1 => {
512            if let StringPart::Literal(s) = &parts[0] {
513                Value::String(s.clone())
514            } else {
515                Value::String("<dynamic>".to_string())
516            }
517        }
518        // For variable refs, command substitution, etc. - use placeholder
519        _ => Value::String("<dynamic>".to_string()),
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::tools::{register_builtins, ToolRegistry};
527
528    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
529        let mut registry = ToolRegistry::new();
530        register_builtins(&mut registry);
531        let user_tools = HashMap::new();
532        (registry, user_tools)
533    }
534
535    #[test]
536    fn validates_undefined_command() {
537        let (registry, user_tools) = make_validator();
538        let validator = Validator::new(&registry, &user_tools);
539
540        let program = Program {
541            statements: vec![Stmt::Command(Command {
542                name: "nonexistent_command".to_string(),
543                args: vec![],
544                redirects: vec![],
545            })],
546        };
547
548        let issues = validator.validate(&program);
549        assert!(!issues.is_empty());
550        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
551    }
552
553    #[test]
554    fn validates_known_command() {
555        let (registry, user_tools) = make_validator();
556        let validator = Validator::new(&registry, &user_tools);
557
558        let program = Program {
559            statements: vec![Stmt::Command(Command {
560                name: "echo".to_string(),
561                args: vec![Arg::Positional(Expr::Literal(Value::String(
562                    "hello".to_string(),
563                )))],
564                redirects: vec![],
565            })],
566        };
567
568        let issues = validator.validate(&program);
569        // echo should not produce an undefined command error
570        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
571    }
572
573    #[test]
574    fn validates_break_outside_loop() {
575        let (registry, user_tools) = make_validator();
576        let validator = Validator::new(&registry, &user_tools);
577
578        let program = Program {
579            statements: vec![Stmt::Break(None)],
580        };
581
582        let issues = validator.validate(&program);
583        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
584    }
585
586    #[test]
587    fn validates_break_inside_loop() {
588        let (registry, user_tools) = make_validator();
589        let validator = Validator::new(&registry, &user_tools);
590
591        let program = Program {
592            statements: vec![Stmt::For(ForLoop {
593                variable: "i".to_string(),
594                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
595                body: vec![Stmt::Break(None)],
596            })],
597        };
598
599        let issues = validator.validate(&program);
600        // Break inside loop should NOT produce an error
601        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
602    }
603
604    #[test]
605    fn validates_undefined_variable() {
606        let (registry, user_tools) = make_validator();
607        let validator = Validator::new(&registry, &user_tools);
608
609        let program = Program {
610            statements: vec![Stmt::Command(Command {
611                name: "echo".to_string(),
612                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
613                    "UNDEFINED_VAR",
614                )))],
615                redirects: vec![],
616            })],
617        };
618
619        let issues = validator.validate(&program);
620        assert!(issues
621            .iter()
622            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
623    }
624
625    #[test]
626    fn validates_defined_variable() {
627        let (registry, user_tools) = make_validator();
628        let validator = Validator::new(&registry, &user_tools);
629
630        let program = Program {
631            statements: vec![
632                // First assign the variable
633                Stmt::Assignment(Assignment {
634                    name: "MY_VAR".to_string(),
635                    value: Expr::Literal(Value::String("value".to_string())),
636                    local: false,
637                }),
638                // Then use it
639                Stmt::Command(Command {
640                    name: "echo".to_string(),
641                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
642                    redirects: vec![],
643                }),
644            ],
645        };
646
647        let issues = validator.validate(&program);
648        // Should NOT warn about MY_VAR
649        assert!(!issues
650            .iter()
651            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
652                && i.message.contains("MY_VAR")));
653    }
654
655    #[test]
656    fn skips_underscore_prefixed_vars() {
657        let (registry, user_tools) = make_validator();
658        let validator = Validator::new(&registry, &user_tools);
659
660        let program = Program {
661            statements: vec![Stmt::Command(Command {
662                name: "echo".to_string(),
663                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
664                redirects: vec![],
665            })],
666        };
667
668        let issues = validator.validate(&program);
669        // Should NOT warn about _EXTERNAL
670        assert!(!issues
671            .iter()
672            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
673    }
674
675    #[test]
676    fn builtin_vars_are_defined() {
677        let (registry, user_tools) = make_validator();
678        let validator = Validator::new(&registry, &user_tools);
679
680        let program = Program {
681            statements: vec![Stmt::Command(Command {
682                name: "echo".to_string(),
683                args: vec![
684                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
685                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
686                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
687                ],
688                redirects: vec![],
689            })],
690        };
691
692        let issues = validator.validate(&program);
693        // Should NOT warn about HOME, PATH, PWD
694        assert!(!issues
695            .iter()
696            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
697    }
698
699    #[test]
700    fn validates_scatter_without_gather() {
701        let (registry, user_tools) = make_validator();
702        let validator = Validator::new(&registry, &user_tools);
703
704        let program = Program {
705            statements: vec![Stmt::Pipeline(Pipeline {
706                commands: vec![
707                    Command { name: "seq".to_string(), args: vec![
708                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
709                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
710                    ], redirects: vec![] },
711                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
712                    Command { name: "echo".to_string(), args: vec![
713                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
714                    ], redirects: vec![] },
715                ],
716                background: false,
717            })],
718        };
719
720        let issues = validator.validate(&program);
721        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
722            "should flag scatter without gather: {:?}", issues);
723    }
724
725    #[test]
726    fn allows_scatter_with_gather() {
727        let (registry, user_tools) = make_validator();
728        let validator = Validator::new(&registry, &user_tools);
729
730        let program = Program {
731            statements: vec![Stmt::Pipeline(Pipeline {
732                commands: vec![
733                    Command { name: "seq".to_string(), args: vec![
734                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
735                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
736                    ], redirects: vec![] },
737                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
738                    Command { name: "echo".to_string(), args: vec![
739                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
740                    ], redirects: vec![] },
741                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
742                ],
743                background: false,
744            })],
745        };
746
747        let issues = validator.validate(&program);
748        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
749            "scatter with gather should pass: {:?}", issues);
750    }
751}