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