Skip to main content

kaish_kernel/validator/
walker.rs

1//! AST walker for pre-execution validation.
2
3use std::collections::{HashMap, HashSet};
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::kernel::{bind_glued_short_value, push_repeatable_value};
10use crate::scheduler::{is_bool_type, schema_param_lookup};
11use crate::validator::issue::Span;
12use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
13
14use super::issue::{IssueCode, ValidationIssue};
15use super::scope_tracker::ScopeTracker;
16
17/// AST validator that checks for issues before execution.
18pub struct Validator<'a> {
19    /// Reference to the tool registry.
20    registry: &'a ToolRegistry,
21    /// User-defined tools.
22    user_tools: &'a HashMap<String, ToolDef>,
23    /// Variable scope tracker.
24    scope: ScopeTracker,
25    /// Current loop nesting depth.
26    loop_depth: usize,
27    /// Current function nesting depth.
28    function_depth: usize,
29    /// Collected validation issues.
30    issues: Vec<ValidationIssue>,
31}
32
33impl<'a> Validator<'a> {
34    /// Create a new validator.
35    pub fn new(registry: &'a ToolRegistry, user_tools: &'a HashMap<String, ToolDef>) -> Self {
36        Self {
37            registry,
38            user_tools,
39            scope: ScopeTracker::new(),
40            loop_depth: 0,
41            function_depth: 0,
42            issues: Vec::new(),
43        }
44    }
45
46    /// Validate a program and return all issues found.
47    pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
48        for stmt in &program.statements {
49            self.validate_stmt(stmt);
50        }
51        self.issues
52    }
53
54    /// Validate a single statement.
55    fn validate_stmt(&mut self, stmt: &Stmt) {
56        match stmt {
57            Stmt::Assignment(assign) => self.validate_assignment(assign),
58            Stmt::Command(cmd) => self.validate_command(cmd),
59            Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
60            Stmt::If(if_stmt) => self.validate_if(if_stmt),
61            Stmt::For(for_loop) => self.validate_for(for_loop),
62            Stmt::While(while_loop) => self.validate_while(while_loop),
63            Stmt::Case(case_stmt) => self.validate_case(case_stmt),
64            Stmt::Break(levels) => self.validate_break(*levels),
65            Stmt::Continue(levels) => self.validate_continue(*levels),
66            Stmt::Return(expr) => self.validate_return(expr.as_deref()),
67            Stmt::Exit(expr) => {
68                if let Some(e) = expr {
69                    self.validate_expr(e);
70                }
71            }
72            Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
73            Stmt::Test(test_expr) => self.validate_test(test_expr),
74            Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
75                self.validate_stmt(left);
76                self.validate_stmt(right);
77            }
78            Stmt::EnvScoped { assignments, body } => {
79                // Validate each prefix assignment (values + bind the name so the
80                // body's references resolve), then the command it scopes.
81                for assign in assignments {
82                    self.validate_assignment(assign);
83                }
84                self.validate_stmt(body);
85            }
86            Stmt::Empty => {}
87        }
88    }
89
90    /// Validate an assignment statement.
91    fn validate_assignment(&mut self, assign: &Assignment) {
92        // Validate the value expression
93        self.validate_expr(&assign.value);
94        // Bind the variable name in scope
95        self.scope.bind(&assign.name);
96    }
97
98    /// Validate a command invocation.
99    fn validate_command(&mut self, cmd: &Command) {
100        // Skip source/. commands - they're dynamic
101        if cmd.name == "source" || cmd.name == "." {
102            return;
103        }
104
105        // Skip dynamic command names (variable expansions)
106        if !is_static_command_name(&cmd.name) {
107            return;
108        }
109
110        // Check if command exists
111        let is_builtin = self.registry.contains(&cmd.name);
112        let is_user_tool = self.user_tools.contains_key(&cmd.name);
113        let is_special = is_special_command(&cmd.name);
114
115        if !is_builtin && !is_user_tool && !is_special {
116            // `[ … ]` can't reach here — it's a parse error (`[` opens a `[[ ]]`
117            // test), so `test` is the only POSIX-conditional name that lands as a
118            // command. kaish has no `test` builtin; unguarded it resolves to an
119            // external binary that evaluates against the real host FS, bypassing
120            // the VFS/overlay — a silent wrong answer flowing into `if`/`&&`.
121            // Steer to `[[ … ]]`. Surfaced to the agent (see surfaces_to_agent).
122            if cmd.name == "test" {
123                self.issues.push(ValidationIssue::warning(
124                    IssueCode::PosixTestCommand,
125                    "'test' is not a kaish command".to_string(),
126                ).with_suggestion(
127                    "use [[ … ]] for conditionals — it is validated before running, \
128                     whereas `test` resolves to an external command that bypasses the VFS",
129                ));
130            } else {
131                // Warning only - command might be a script in PATH or external tool
132                self.issues.push(ValidationIssue::warning(
133                    IssueCode::UndefinedCommand,
134                    format!("command '{}' not found in builtin registry", cmd.name),
135                ).with_suggestion("this may be a script in PATH or external command"));
136            }
137        }
138
139        // Validate arguments expressions
140        for arg in &cmd.args {
141            self.validate_arg(arg);
142        }
143
144        // If we have a schema, validate args against it. Pass the schema so the
145        // arg-builder binds glued/value short-flags the same way execute does —
146        // otherwise a tool whose validate() reads positionals semantically (sed,
147        // awk) misreads them (docs/issues.md: schema-blind validation builder).
148        if let Some(tool) = self.registry.get(&cmd.name) {
149            let schema = tool.schema();
150            let tool_args = build_tool_args_for_validation(&cmd.args, Some(&schema));
151            let tool_issues = tool.validate(&tool_args);
152            self.issues.extend(tool_issues);
153        } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
154            // Validate against user-defined tool parameters
155            self.validate_user_tool_args(user_tool, &cmd.args);
156        }
157
158        // Validate redirects
159        for redirect in &cmd.redirects {
160            self.validate_expr(&redirect.target);
161        }
162    }
163
164    /// Validate a command argument.
165    fn validate_arg(&mut self, arg: &Arg) {
166        match arg {
167            Arg::Positional(expr) => self.validate_expr(expr),
168            Arg::Named { value, .. } => self.validate_expr(value),
169            Arg::WordAssign { value, .. } => self.validate_expr(value),
170            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
171        }
172    }
173
174    /// Validate a pipeline.
175    fn validate_pipeline(&mut self, pipe: &Pipeline) {
176        // Check for scatter without gather
177        let has_scatter = pipe.commands.iter().any(|c| c.name == "scatter");
178        let has_gather = pipe.commands.iter().any(|c| c.name == "gather");
179        if has_scatter && !has_gather {
180            self.issues.push(
181                ValidationIssue::error(
182                    IssueCode::ScatterWithoutGather,
183                    "scatter without gather — parallel results would be lost",
184                ).with_suggestion("add gather: ... | scatter | cmd | gather")
185            );
186        }
187
188        for cmd in &pipe.commands {
189            self.validate_command(cmd);
190        }
191    }
192
193    /// Validate an if statement.
194    fn validate_if(&mut self, if_stmt: &IfStmt) {
195        self.validate_expr(&if_stmt.condition);
196
197        self.scope.push_frame();
198        for stmt in &if_stmt.then_branch {
199            self.validate_stmt(stmt);
200        }
201        self.scope.pop_frame();
202
203        if let Some(else_branch) = &if_stmt.else_branch {
204            self.scope.push_frame();
205            for stmt in else_branch {
206                self.validate_stmt(stmt);
207            }
208            self.scope.pop_frame();
209        }
210    }
211
212    /// Validate a for loop.
213    fn validate_for(&mut self, for_loop: &ForLoop) {
214        // Validate item expressions and check for bare scalar variables
215        for item in &for_loop.items {
216            self.validate_expr(item);
217
218            // Detect `for i in $VAR` pattern - always a mistake in kaish
219            // since we don't do implicit word splitting
220            if self.is_bare_scalar_var(item) {
221                self.issues.push(
222                    ValidationIssue::error(
223                        IssueCode::ForLoopScalarVar,
224                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
225                    )
226                    .with_suggestion(concat!(
227                        "use one of:\n",
228                        "    for i in $(split \"$VAR\")      # split on whitespace\n",
229                        "    for i in $(split \"$VAR\" \":\")  # split on delimiter\n",
230                        "    for i in $(seq 1 10)          # iterate numbers\n",
231                        "    for i in $(glob \"*.rs\")       # iterate files",
232                    )),
233                );
234            }
235        }
236
237        self.loop_depth += 1;
238        self.scope.push_frame();
239
240        // Bind loop variable
241        self.scope.bind(&for_loop.variable);
242
243        for stmt in &for_loop.body {
244            self.validate_stmt(stmt);
245        }
246
247        self.scope.pop_frame();
248        self.loop_depth -= 1;
249    }
250
251    /// Extract glob pattern from unquoted literal expressions.
252    ///
253    /// Check if an expression is a bare scalar variable reference.
254    ///
255    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
256    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
257        match expr {
258            // Direct variable reference like $VAR or ${VAR}
259            Expr::VarRef(_) => true,
260            // Variable with default like ${VAR:-default} - also problematic
261            Expr::VarWithDefault { .. } => true,
262            // NOT a problem: command substitution like $(cmd) - returns structured data
263            Expr::CommandSubst(_) => false,
264            // NOT a problem: literals are fine
265            Expr::Literal(_) => false,
266            // NOT a problem: interpolated strings are a single value
267            Expr::Interpolated(_) => false,
268            // Everything else: not a bare scalar var
269            _ => false,
270        }
271    }
272
273    /// Validate a while loop.
274    fn validate_while(&mut self, while_loop: &WhileLoop) {
275        self.validate_expr(&while_loop.condition);
276
277        self.loop_depth += 1;
278        self.scope.push_frame();
279
280        for stmt in &while_loop.body {
281            self.validate_stmt(stmt);
282        }
283
284        self.scope.pop_frame();
285        self.loop_depth -= 1;
286    }
287
288    /// Validate a case statement.
289    fn validate_case(&mut self, case_stmt: &CaseStmt) {
290        self.validate_expr(&case_stmt.expr);
291
292        for branch in &case_stmt.branches {
293            self.validate_case_branch(branch);
294        }
295    }
296
297    /// Validate a case branch.
298    fn validate_case_branch(&mut self, branch: &CaseBranch) {
299        self.scope.push_frame();
300        for stmt in &branch.body {
301            self.validate_stmt(stmt);
302        }
303        self.scope.pop_frame();
304    }
305
306    /// Validate a break statement.
307    fn validate_break(&mut self, levels: Option<usize>) {
308        if self.loop_depth == 0 {
309            self.issues.push(ValidationIssue::error(
310                IssueCode::BreakOutsideLoop,
311                "break used outside of a loop",
312            ));
313        } else if let Some(n) = levels
314            && n > self.loop_depth {
315                self.issues.push(ValidationIssue::warning(
316                    IssueCode::BreakOutsideLoop,
317                    format!(
318                        "break {} exceeds loop nesting depth {}",
319                        n, self.loop_depth
320                    ),
321                ));
322            }
323    }
324
325    /// Validate a continue statement.
326    fn validate_continue(&mut self, levels: Option<usize>) {
327        if self.loop_depth == 0 {
328            self.issues.push(ValidationIssue::error(
329                IssueCode::BreakOutsideLoop,
330                "continue used outside of a loop",
331            ));
332        } else if let Some(n) = levels
333            && n > self.loop_depth {
334                self.issues.push(ValidationIssue::warning(
335                    IssueCode::BreakOutsideLoop,
336                    format!(
337                        "continue {} exceeds loop nesting depth {}",
338                        n, self.loop_depth
339                    ),
340                ));
341            }
342    }
343
344    /// Validate a return statement.
345    fn validate_return(&mut self, expr: Option<&Expr>) {
346        if let Some(e) = expr {
347            self.validate_expr(e);
348        }
349
350        if self.function_depth == 0 {
351            self.issues.push(ValidationIssue::error(
352                IssueCode::ReturnOutsideFunction,
353                "return used outside of a function",
354            ));
355        }
356    }
357
358    /// Validate a tool definition.
359    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
360        self.function_depth += 1;
361        self.scope.push_frame();
362
363        // Bind parameters
364        for param in &tool_def.params {
365            self.scope.bind(&param.name);
366            // Validate default expressions
367            if let Some(default) = &param.default {
368                self.validate_expr(default);
369            }
370        }
371
372        // Validate body
373        for stmt in &tool_def.body {
374            self.validate_stmt(stmt);
375        }
376
377        self.scope.pop_frame();
378        self.function_depth -= 1;
379    }
380
381    /// Validate a test expression.
382    fn validate_test(&mut self, test: &TestExpr) {
383        match test {
384            TestExpr::FileTest { path, .. } => self.validate_expr(path),
385            TestExpr::StringTest { value, .. } => self.validate_expr(value),
386            TestExpr::Comparison { left, right, .. } => {
387                self.validate_expr(left);
388                self.validate_expr(right);
389            }
390            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
391                self.validate_test(left);
392                self.validate_test(right);
393            }
394            TestExpr::Not { expr } => self.validate_test(expr),
395        }
396    }
397
398    /// Validate an expression.
399    fn validate_expr(&mut self, expr: &Expr) {
400        match expr {
401            Expr::Literal(_) => {}
402            Expr::VarRef(path) => self.validate_var_ref(path),
403            Expr::Interpolated(parts) => {
404                for part in parts {
405                    self.validate_string_part(part);
406                }
407            }
408            Expr::HereDocBody { parts, .. } => {
409                for sp in parts {
410                    self.validate_spanned_string_part(sp);
411                }
412            }
413            Expr::BinaryOp { left, right, .. } => {
414                self.validate_expr(left);
415                self.validate_expr(right);
416            }
417            Expr::CommandSubst(stmts) => {
418                for stmt in stmts {
419                    self.validate_stmt(stmt);
420                }
421            }
422            Expr::Test(test) => self.validate_test(test),
423            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
424            Expr::VarLength(name) => self.check_var_defined(name),
425            Expr::VarWithDefault { name, .. } => {
426                // Don't warn - default handles undefined case
427                let _ = name;
428            }
429            Expr::Arithmetic(_) => {
430                // Arithmetic parsing is done at runtime
431            }
432            Expr::Command(cmd) => self.validate_command(cmd),
433            Expr::LastExitCode | Expr::CurrentPid => {}
434            Expr::GlobPattern(_) => {}
435        }
436    }
437
438    /// Validate a variable reference.
439    fn validate_var_ref(&mut self, path: &VarPath) {
440        if let Some(VarSegment::Field(name)) = path.segments.first() {
441            // `${?.field}` is removed — $? is the POSIX integer exit code.
442            // Use `kaish-last` to access the previous command's structured data.
443            if name == "?" && path.segments.len() > 1 {
444                self.issues.push(
445                    ValidationIssue::error(
446                        IssueCode::LastResultFieldAccess,
447                        "${?.field} is removed; $? is the POSIX exit code",
448                    )
449                    .with_suggestion(
450                        "use `kaish-last` to read the previous command's data or stdout",
451                    ),
452                );
453                return;
454            }
455            self.check_var_defined(name);
456        }
457    }
458
459    /// Validate a spanned heredoc-body part, attaching the part's span to any
460    /// new issues raised during the inner walk. Issues already carrying a span
461    /// are left alone (e.g., from a nested validator that already knew better).
462    fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
463        let issues_before = self.issues.len();
464        self.validate_string_part(&sp.part);
465        let span = Span::new(sp.offset, sp.offset + sp.len);
466        for issue in &mut self.issues[issues_before..] {
467            if issue.span.is_none() {
468                issue.span = Some(span);
469            }
470        }
471    }
472
473    /// Validate a string interpolation part.
474    fn validate_string_part(&mut self, part: &StringPart) {
475        match part {
476            StringPart::Literal(_) => {}
477            StringPart::Var(path) => self.validate_var_ref(path),
478            StringPart::VarWithDefault { default, .. } => {
479                // Validate nested parts in the default value
480                for p in default {
481                    self.validate_string_part(p);
482                }
483            }
484            StringPart::VarLength(name) => self.check_var_defined(name),
485            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
486            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
487            StringPart::CommandSubst(stmts) => {
488                for stmt in stmts {
489                    self.validate_stmt(stmt);
490                }
491            }
492            StringPart::LastExitCode | StringPart::CurrentPid => {}
493        }
494    }
495
496    /// Check if a variable is defined and warn if not.
497    fn check_var_defined(&mut self, name: &str) {
498        // Skip underscore-prefixed vars (external/unchecked convention)
499        if ScopeTracker::should_skip_undefined_check(name) {
500            return;
501        }
502
503        if !self.scope.is_bound(name) {
504            self.issues.push(ValidationIssue::warning(
505                IssueCode::PossiblyUndefinedVariable,
506                format!("variable '{}' may be undefined", name),
507            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
508        }
509    }
510
511    /// Validate arguments against a user-defined tool's parameters.
512    ///
513    /// Counts bareword `key=value` (`Arg::WordAssign`) as positional: user
514    /// tools aren't on `WORD_ASSIGN_BUILTINS`, so at runtime the kernel
515    /// stringifies WordAssign into a positional `"key=value"` (matches bash).
516    /// Skipping it here would falsely error `mytool foo=bar` when mytool has
517    /// one required positional.
518    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
519        let positional_count = args
520            .iter()
521            .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
522            .count();
523
524        let required_count = tool_def
525            .params
526            .iter()
527            .filter(|p| p.default.is_none())
528            .count();
529
530        if positional_count < required_count {
531            self.issues.push(ValidationIssue::error(
532                IssueCode::MissingRequiredArg,
533                format!(
534                    "'{}' requires {} arguments, got {}",
535                    tool_def.name, required_count, positional_count
536                ),
537            ));
538        }
539    }
540}
541
542/// Check if a command name is static (not a variable expansion).
543fn is_static_command_name(name: &str) -> bool {
544    !name.starts_with('$') && !name.contains("$(")
545}
546
547/// Check if a command is a special built-in that we don't validate.
548fn is_special_command(name: &str) -> bool {
549    // `test`/`[`/`[[` are intentionally absent: there is no `test` builtin
550    // (it gets the PosixTestCommand advisory above), and `[`/`[[` parse as
551    // `[[ … ]]` test expressions, never reaching here as a command name.
552    matches!(name, "true" | "false" | ":" | "readonly" | "local")
553}
554
555/// Build ToolArgs from AST Args for validation purposes.
556///
557/// This is a simplified version that doesn't evaluate expressions -
558/// it uses placeholder values since we only care about argument structure.
559pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
560    let mut tool_args = ToolArgs::new();
561    // Schema-aware param table: flag name → (canonical, type, consumes, repeatable).
562    // Empty when there's no schema, in which case every flag stays a bare flag
563    // (the old schema-blind behavior).
564    let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
565    let mut consumed: HashSet<usize> = HashSet::new();
566    let mut past_double_dash = false;
567
568    for i in 0..args.len() {
569        match &args[i] {
570            Arg::DoubleDash => past_double_dash = true,
571            Arg::Positional(expr) => {
572                if !consumed.contains(&i) {
573                    tool_args.positional.push(expr_to_placeholder(expr));
574                }
575            }
576            Arg::Named { key, value } => {
577                let v = expr_to_placeholder(value);
578                match param_lookup.get(key.as_str()) {
579                    // Repeatable `--flag=a --flag=b` accumulates (matches execute).
580                    Some(&(canonical, _, _, true)) => {
581                        let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
582                    }
583                    Some(&(canonical, ..)) => {
584                        tool_args.named.insert(canonical.to_string(), v);
585                    }
586                    None => {
587                        tool_args.named.insert(key.clone(), v);
588                    }
589                }
590            }
591            Arg::WordAssign { key, value } => {
592                // Validation walker doesn't know which command is receiving;
593                // route into named like the legacy behavior so checks stay
594                // consistent with previous validator output.
595                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
596            }
597            Arg::ShortFlag(name) => {
598                if past_double_dash {
599                    tool_args.positional.push(Value::String(format!("-{name}")));
600                } else {
601                    bind_short_flag_for_validation(
602                        name,
603                        &param_lookup,
604                        args,
605                        i,
606                        &mut consumed,
607                        &mut tool_args,
608                    );
609                }
610            }
611            Arg::LongFlag(name) => {
612                if past_double_dash {
613                    tool_args.positional.push(Value::String(format!("--{name}")));
614                } else {
615                    match param_lookup.get(name.as_str()) {
616                        Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
617                            bind_value_or_flag(
618                                &mut tool_args, name, canonical, consumes, repeatable, args, i,
619                                &mut consumed,
620                            );
621                        }
622                        Some(&(canonical, ..)) => {
623                            tool_args.flags.insert(canonical.to_string());
624                        }
625                        None => {
626                            tool_args.flags.insert(name.clone());
627                        }
628                    }
629                }
630            }
631        }
632    }
633
634    tool_args
635}
636
637/// Bind a (possibly glued/combined) short-flag token, schema-aware, mirroring
638/// `kernel::build_args_async`: a value-taking first char consumes the rest of the
639/// token as its glued value (`-e1d` → e=`1d`) or, if it is the last char, the next
640/// positional (`-e d` → e=`d`); bool flags stack (`-la`). Unknown chars stay bare
641/// flags so a schemaless tool keeps all-boolean behavior.
642fn bind_short_flag_for_validation(
643    name: &str,
644    param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
645    args: &[Arg],
646    i: usize,
647    consumed: &mut HashSet<usize>,
648    tool_args: &mut ToolArgs,
649) {
650    // Whole-name match first (POSIX `-name value` or a multi-char bool).
651    if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
652        if is_bool_type(typ) {
653            tool_args.flags.insert(canonical.to_string());
654        } else {
655            bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
656        }
657        return;
658    }
659    // First char is a declared value-taking short flag: the tail is its glued value.
660    if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
661        .get(&name[..1])
662        .filter(|(_, typ, ..)| !is_bool_type(typ))
663    {
664        let glued = name[1..].to_string();
665        if glued.is_empty() {
666            bind_value_or_flag(
667                tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
668            );
669        } else {
670            let _ =
671                bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
672        }
673        return;
674    }
675    // Combined short flags: bools stack until the first value-taking char, which
676    // consumes the rest of the token (or the next positional).
677    let bytes = name.as_bytes();
678    let mut p = 0;
679    while p < bytes.len() {
680        let key = &name[p..p + 1];
681        match param_lookup.get(key) {
682            Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
683                let glued = name[p + 1..].to_string();
684                if glued.is_empty() {
685                    bind_value_or_flag(
686                        tool_args, key, canonical, consumes, repeatable, args, i, consumed,
687                    );
688                } else {
689                    let _ = bind_glued_short_value(
690                        tool_args, key, canonical, consumes, repeatable, glued,
691                    );
692                }
693                return;
694            }
695            _ => {
696                tool_args.flags.insert(key.to_string());
697                p += 1;
698            }
699        }
700    }
701}
702
703/// Bind a bare value-flag by consuming the next `consumes` not-yet-consumed
704/// positionals as its value(s) — mirroring `kernel::consume_flag_positionals`:
705/// a single-value flag stores a scalar (repeatable → canonical array), a
706/// multi-value flag (`jq --arg NAME VALUE`, `consumes==2`) stores an
707/// array-of-arrays. A single-value flag may also consume a `key=value`
708/// (`awk -v a=1`); multi-value flags take plain positionals only. With nothing
709/// to consume it falls back to a bare flag.
710#[allow(clippy::too_many_arguments)] // mirrors kernel::consume_flag_positionals
711fn bind_value_or_flag(
712    tool_args: &mut ToolArgs,
713    flag_name: &str,
714    canonical: &str,
715    consumes: usize,
716    repeatable: bool,
717    args: &[Arg],
718    i: usize,
719    consumed: &mut HashSet<usize>,
720) {
721    let want = consumes.max(1);
722    let allow_word_assign = consumes <= 1;
723    let mut collected: Vec<Value> = Vec::with_capacity(want);
724    for _ in 0..want {
725        let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
726            let idx = i + 1 + off;
727            if consumed.contains(&idx) {
728                return None;
729            }
730            match a {
731                Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
732                Arg::WordAssign { key, value } if allow_word_assign => {
733                    let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
734                    Some((idx, Value::String(format!("{key}={s}"))))
735                }
736                _ => None,
737            }
738        });
739        match found {
740            Some((idx, v)) => {
741                consumed.insert(idx);
742                collected.push(v);
743            }
744            None => break,
745        }
746    }
747
748    if collected.is_empty() {
749        tool_args.flags.insert(canonical.to_string());
750        return;
751    }
752    if consumes <= 1 {
753        if let Some(v) = collected.into_iter().next() {
754            if repeatable {
755                let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
756            } else {
757                tool_args.named.insert(canonical.to_string(), v);
758            }
759        }
760        return;
761    }
762    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
763    let occ: Vec<serde_json::Value> = collected
764        .iter()
765        .map(crate::interpreter::value_to_json)
766        .collect();
767    let entry = tool_args
768        .named
769        .entry(canonical.to_string())
770        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
771    if let Value::Json(serde_json::Value::Array(outer)) = entry {
772        outer.push(serde_json::Value::Array(occ));
773    }
774}
775
776/// Convert an expression to a placeholder value for validation.
777///
778/// For literal values, return the actual value.
779/// For dynamic expressions (var refs, command subst), return a placeholder.
780fn expr_to_placeholder(expr: &Expr) -> Value {
781    match expr {
782        Expr::Literal(val) => val.clone(),
783        Expr::Interpolated(parts) if parts.len() == 1 => {
784            if let StringPart::Literal(s) = &parts[0] {
785                Value::String(s.clone())
786            } else {
787                Value::String("<dynamic>".to_string())
788            }
789        }
790        // For variable refs, command substitution, etc. - use placeholder
791        _ => Value::String("<dynamic>".to_string()),
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::tools::{register_builtins, ToolRegistry};
799
800    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
801        let mut registry = ToolRegistry::new();
802        register_builtins(&mut registry);
803        let user_tools = HashMap::new();
804        (registry, user_tools)
805    }
806
807    #[test]
808    fn validates_undefined_command() {
809        let (registry, user_tools) = make_validator();
810        let validator = Validator::new(&registry, &user_tools);
811
812        let program = Program {
813            statements: vec![Stmt::Command(Command {
814                name: "nonexistent_command".to_string(),
815                args: vec![],
816                redirects: vec![],
817            })],
818        };
819
820        let issues = validator.validate(&program);
821        assert!(!issues.is_empty());
822        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
823    }
824
825    #[test]
826    fn test_command_steers_to_double_bracket() {
827        let (registry, user_tools) = make_validator();
828        let validator = Validator::new(&registry, &user_tools);
829
830        let program = Program {
831            statements: vec![Stmt::Command(Command {
832                name: "test".to_string(),
833                args: vec![
834                    Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
835                    Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
836                ],
837                redirects: vec![],
838            })],
839        };
840
841        let issues = validator.validate(&program);
842        let issue = issues
843            .iter()
844            .find(|i| i.code == IssueCode::PosixTestCommand)
845            .expect("`test` should emit a PosixTestCommand advisory");
846        assert_eq!(issue.severity, crate::validator::Severity::Warning);
847        assert!(issue.code.surfaces_to_agent(), "the advisory must surface to the agent");
848        assert!(issue.suggestion.as_deref().unwrap_or_default().contains("[["));
849        // It must NOT also fire the generic undefined-command warning.
850        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
851    }
852
853    #[test]
854    fn path_qualified_test_is_honored_not_steered() {
855        // A user who writes a path clearly wants that external binary — only the
856        // bare ident `test` is the POSIX-conditional footgun. The parser keeps
857        // the path in `cmd.name` (path_parser / DotSlashPath), so the advisory's
858        // `cmd.name == "test"` guard naturally excludes these.
859        let (registry, user_tools) = make_validator();
860
861        for name in ["./test", "/opt/custom/test"] {
862            let validator = Validator::new(&registry, &user_tools);
863            let program = Program {
864                statements: vec![Stmt::Command(Command {
865                    name: name.to_string(),
866                    args: vec![],
867                    redirects: vec![],
868                })],
869            };
870            let issues = validator.validate(&program);
871            assert!(
872                !issues.iter().any(|i| i.code == IssueCode::PosixTestCommand),
873                "path-qualified '{name}' must not get the test advisory"
874            );
875        }
876    }
877
878    #[test]
879    fn validates_known_command() {
880        let (registry, user_tools) = make_validator();
881        let validator = Validator::new(&registry, &user_tools);
882
883        let program = Program {
884            statements: vec![Stmt::Command(Command {
885                name: "echo".to_string(),
886                args: vec![Arg::Positional(Expr::Literal(Value::String(
887                    "hello".to_string(),
888                )))],
889                redirects: vec![],
890            })],
891        };
892
893        let issues = validator.validate(&program);
894        // echo should not produce an undefined command error
895        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
896    }
897
898    #[test]
899    fn glued_value_flags_dont_false_error_at_validation() {
900        // Regression for the schema-blind validation builder (docs/issues.md):
901        // `sed -e1d -e2d FILE` must validate clean. Before schema-aware binding,
902        // the glued `-e` values weren't split, so `collect_expressions` fell back
903        // to parsing the FILE PATH as the sed program — a path-dependent false
904        // E006 (here `file.txt` → its leading `f` is an "unknown command").
905        let (registry, user_tools) = make_validator();
906        let validator = Validator::new(&registry, &user_tools);
907
908        let program = Program {
909            statements: vec![Stmt::Command(Command {
910                name: "sed".to_string(),
911                args: vec![
912                    Arg::ShortFlag("e1d".to_string()),
913                    Arg::ShortFlag("e2d".to_string()),
914                    Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
915                ],
916                redirects: vec![],
917            })],
918        };
919
920        let issues = validator.validate(&program);
921        assert!(
922            !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
923            "glued -e flags false-errored at validation: {:?}",
924            issues.iter().map(|i| &i.message).collect::<Vec<_>>()
925        );
926    }
927
928    #[test]
929    fn validates_break_outside_loop() {
930        let (registry, user_tools) = make_validator();
931        let validator = Validator::new(&registry, &user_tools);
932
933        let program = Program {
934            statements: vec![Stmt::Break(None)],
935        };
936
937        let issues = validator.validate(&program);
938        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
939    }
940
941    #[test]
942    fn validates_break_inside_loop() {
943        let (registry, user_tools) = make_validator();
944        let validator = Validator::new(&registry, &user_tools);
945
946        let program = Program {
947            statements: vec![Stmt::For(ForLoop {
948                variable: "i".to_string(),
949                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
950                body: vec![Stmt::Break(None)],
951            })],
952        };
953
954        let issues = validator.validate(&program);
955        // Break inside loop should NOT produce an error
956        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
957    }
958
959    #[test]
960    fn validates_undefined_variable() {
961        let (registry, user_tools) = make_validator();
962        let validator = Validator::new(&registry, &user_tools);
963
964        let program = Program {
965            statements: vec![Stmt::Command(Command {
966                name: "echo".to_string(),
967                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
968                    "UNDEFINED_VAR",
969                )))],
970                redirects: vec![],
971            })],
972        };
973
974        let issues = validator.validate(&program);
975        assert!(issues
976            .iter()
977            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
978    }
979
980    #[test]
981    fn validates_defined_variable() {
982        let (registry, user_tools) = make_validator();
983        let validator = Validator::new(&registry, &user_tools);
984
985        let program = Program {
986            statements: vec![
987                // First assign the variable
988                Stmt::Assignment(Assignment {
989                    name: "MY_VAR".to_string(),
990                    value: Expr::Literal(Value::String("value".to_string())),
991                    local: false,
992                }),
993                // Then use it
994                Stmt::Command(Command {
995                    name: "echo".to_string(),
996                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
997                    redirects: vec![],
998                }),
999            ],
1000        };
1001
1002        let issues = validator.validate(&program);
1003        // Should NOT warn about MY_VAR
1004        assert!(!issues
1005            .iter()
1006            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1007                && i.message.contains("MY_VAR")));
1008    }
1009
1010    #[test]
1011    fn skips_underscore_prefixed_vars() {
1012        let (registry, user_tools) = make_validator();
1013        let validator = Validator::new(&registry, &user_tools);
1014
1015        let program = Program {
1016            statements: vec![Stmt::Command(Command {
1017                name: "echo".to_string(),
1018                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1019                redirects: vec![],
1020            })],
1021        };
1022
1023        let issues = validator.validate(&program);
1024        // Should NOT warn about _EXTERNAL
1025        assert!(!issues
1026            .iter()
1027            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1028    }
1029
1030    #[test]
1031    fn builtin_vars_are_defined() {
1032        let (registry, user_tools) = make_validator();
1033        let validator = Validator::new(&registry, &user_tools);
1034
1035        let program = Program {
1036            statements: vec![Stmt::Command(Command {
1037                name: "echo".to_string(),
1038                args: vec![
1039                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1040                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1041                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1042                ],
1043                redirects: vec![],
1044            })],
1045        };
1046
1047        let issues = validator.validate(&program);
1048        // Should NOT warn about HOME, PATH, PWD
1049        assert!(!issues
1050            .iter()
1051            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1052    }
1053
1054    #[test]
1055    fn validates_scatter_without_gather() {
1056        let (registry, user_tools) = make_validator();
1057        let validator = Validator::new(&registry, &user_tools);
1058
1059        let program = Program {
1060            statements: vec![Stmt::Pipeline(Pipeline {
1061                commands: vec![
1062                    Command { name: "seq".to_string(), args: vec![
1063                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
1064                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
1065                    ], redirects: vec![] },
1066                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1067                    Command { name: "echo".to_string(), args: vec![
1068                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1069                    ], redirects: vec![] },
1070                ],
1071                background: false,
1072            })],
1073        };
1074
1075        let issues = validator.validate(&program);
1076        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1077            "should flag scatter without gather: {:?}", issues);
1078    }
1079
1080    #[test]
1081    fn allows_scatter_with_gather() {
1082        let (registry, user_tools) = make_validator();
1083        let validator = Validator::new(&registry, &user_tools);
1084
1085        let program = Program {
1086            statements: vec![Stmt::Pipeline(Pipeline {
1087                commands: vec![
1088                    Command { name: "seq".to_string(), args: vec![
1089                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
1090                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
1091                    ], redirects: vec![] },
1092                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1093                    Command { name: "echo".to_string(), args: vec![
1094                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1095                    ], redirects: vec![] },
1096                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1097                ],
1098                background: false,
1099            })],
1100        };
1101
1102        let issues = validator.validate(&program);
1103        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1104            "scatter with gather should pass: {:?}", issues);
1105    }
1106
1107    fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1108        let mut user_tools = HashMap::new();
1109        user_tools.insert(
1110            "mytool".to_string(),
1111            ToolDef {
1112                name: "mytool".to_string(),
1113                params: vec![crate::ast::ParamDef {
1114                    name: "input".to_string(),
1115                    param_type: None,
1116                    default: None,
1117                }],
1118                body: vec![],
1119            },
1120        );
1121        user_tools
1122    }
1123
1124    /// `mytool foo=bar` should count the bareword `key=value` as a positional
1125    /// because user tools aren't on the WordAssign allowlist — runtime
1126    /// stringifies WordAssign to a positional. Validator must agree.
1127    #[test]
1128    fn user_tool_wordassign_counts_as_positional() {
1129        let mut registry = ToolRegistry::new();
1130        register_builtins(&mut registry);
1131        let user_tools = make_user_tool_with_required_positional();
1132        let validator = Validator::new(&registry, &user_tools);
1133
1134        let program = Program {
1135            statements: vec![Stmt::Command(Command {
1136                name: "mytool".to_string(),
1137                args: vec![Arg::WordAssign {
1138                    key: "foo".to_string(),
1139                    value: Expr::Literal(Value::String("bar".to_string())),
1140                }],
1141                redirects: vec![],
1142            })],
1143        };
1144
1145        let issues = validator.validate(&program);
1146        assert!(
1147            !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1148            "WordAssign should satisfy required positional; got {:?}",
1149            issues
1150        );
1151    }
1152
1153    /// Missing-required-arg still fires when no positional or WordAssign is
1154    /// provided — regression guard so the fix doesn't silently skip the check.
1155    #[test]
1156    fn user_tool_no_args_still_errors() {
1157        let mut registry = ToolRegistry::new();
1158        register_builtins(&mut registry);
1159        let user_tools = make_user_tool_with_required_positional();
1160        let validator = Validator::new(&registry, &user_tools);
1161
1162        let program = Program {
1163            statements: vec![Stmt::Command(Command {
1164                name: "mytool".to_string(),
1165                args: vec![],
1166                redirects: vec![],
1167            })],
1168        };
1169
1170        let issues = validator.validate(&program);
1171        assert!(
1172            issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1173            "missing positional should still error; got {:?}",
1174            issues
1175        );
1176    }
1177}