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