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, ListElem, Pipeline,
7    PipelineStage, Program, SpannedPart, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment,
8    WhileLoop,
9    Value,
10};
11use crate::kernel::{bind_glued_short_value, push_repeatable_value};
12use crate::scheduler::{is_bool_type, schema_param_lookup};
13use crate::validator::issue::Span;
14use crate::tools::{ToolArgs, ToolRegistry, ToolSchema};
15use kaish_types::CommandKind;
16
17use super::issue::{IssueCode, ValidationIssue};
18#[cfg(test)]
19use super::issue::Severity;
20use super::scope_tracker::ScopeTracker;
21
22/// AST validator that checks for issues before execution.
23pub struct Validator<'a> {
24    /// Reference to the tool registry.
25    registry: &'a ToolRegistry,
26    /// User-defined tools.
27    user_tools: &'a HashMap<String, ToolDef>,
28    /// The kernel's name-sorted schema catalog (GH #256); `&[]` when there is
29    /// none. `binary_search_by` returns `Ok` only on an equal compare, so an
30    /// empty or unsorted catalog can only miss — and a miss falls back to
31    /// `tool.schema()`. A *stale* entry is different: it has the right name, so
32    /// it hits, and a hit is what skips the fallback. The kernel cannot go
33    /// stale — the registry is frozen before `set_tool_schemas` snapshots it —
34    /// but an embedder hand-building a catalog can. A hit also assumes
35    /// `tool.name() == tool.schema().name`, which nothing enforces.
36    catalog: &'a [ToolSchema],
37    /// Variable scope tracker.
38    scope: ScopeTracker,
39    /// Current loop nesting depth.
40    loop_depth: usize,
41    /// Current function nesting depth.
42    function_depth: usize,
43    /// Collected validation issues.
44    issues: Vec<ValidationIssue>,
45}
46
47impl<'a> Validator<'a> {
48    /// Create a new validator.
49    ///
50    /// `catalog` is the kernel's name-sorted `Arc<[ToolSchema]>` (see
51    /// `ExecContext::tool_schemas`); pass `&[]` when none is available.
52    pub fn new(
53        registry: &'a ToolRegistry,
54        user_tools: &'a HashMap<String, ToolDef>,
55        catalog: &'a [ToolSchema],
56    ) -> Self {
57        Self {
58            registry,
59            user_tools,
60            catalog,
61            scope: ScopeTracker::new(),
62            loop_depth: 0,
63            function_depth: 0,
64            issues: Vec::new(),
65        }
66    }
67
68    /// Validate a program and return all issues found.
69    pub fn validate(mut self, program: &Program) -> Vec<ValidationIssue> {
70        for stmt in &program.statements {
71            self.validate_stmt(stmt);
72        }
73        self.issues
74    }
75
76    /// Validate a single statement.
77    fn validate_stmt(&mut self, stmt: &Stmt) {
78        match stmt {
79            Stmt::Assignment(assign) => self.validate_assignment(assign),
80            Stmt::Command(cmd) => self.validate_command(cmd),
81            Stmt::Pipeline(pipe) => self.validate_pipeline(pipe),
82            Stmt::If(if_stmt) => self.validate_if(if_stmt),
83            Stmt::For(for_loop) => self.validate_for(for_loop),
84            Stmt::While(while_loop) => self.validate_while(while_loop),
85            Stmt::Case(case_stmt) => self.validate_case(case_stmt),
86            Stmt::Break(levels) => self.validate_break(*levels),
87            Stmt::Continue(levels) => self.validate_continue(*levels),
88            Stmt::Return(expr) => self.validate_return(expr.as_deref()),
89            Stmt::Exit(expr) => {
90                if let Some(e) = expr {
91                    self.validate_expr(e);
92                }
93            }
94            Stmt::ToolDef(tool_def) => self.validate_tool_def(tool_def),
95            Stmt::Test(test_expr) => self.validate_test(test_expr),
96            Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
97                self.validate_stmt(left);
98                self.validate_stmt(right);
99            }
100            Stmt::EnvScoped { assignments, body } => {
101                // Validate each prefix assignment (values + bind the name so the
102                // body's references resolve), then the command it scopes.
103                for assign in assignments {
104                    self.validate_assignment(assign);
105                }
106                self.validate_stmt(body);
107            }
108            Stmt::Empty => {}
109        }
110    }
111
112    /// Validate an assignment statement.
113    ///
114    /// A plain `NAME=value` is checked for a dotted target (`user.email=x`) —
115    /// kaish is brackets-only for collection access, and the `Ident` token
116    /// admits `.` for other uses (filenames), so a dotted assignment target
117    /// is caught here rather than by tightening the lexer regex. A
118    /// subscripted lvalue (`x[k]=v`) additionally requires the root to
119    /// already be bound — a path-set never autovivifies the root. See
120    /// `docs/LANGUAGE.md`, "Assignment — bracket-path lvalues".
121    fn validate_assignment(&mut self, assign: &Assignment) {
122        // Validate the value expression
123        self.validate_expr(&assign.value);
124
125        let name = assign.name();
126
127        // The token-stream scan catches most spellings earlier and with a
128        // tighter span, but it tells a target from an argv `key=value` word by
129        // what precedes it — and in an env-scoped prefix (`x=1 BAD=2 cmd`) the
130        // second target and a command name look identical one token back. Here
131        // the tree already knows this is a target, so the rule is exact.
132        if let Err(bad) = crate::name::validate(name) {
133            // `.` and `#` have their own codes below, which name the corrected
134            // spelling. Reporting the general rule as well would hand the
135            // author two errors for one mistake.
136            if !matches!(bad.ch, '.' | '#') {
137                self.issues.push(ValidationIssue::error(
138                    IssueCode::InvisibleAssignmentTarget,
139                    bad.to_string(),
140                ));
141            }
142        }
143
144        // A name spelled in two scripts binds one variable and reads as
145        // another, with exit code 0 either way. A warning, not an error — see
146        // `crate::name::mixed_script`.
147        if let Some(mixed) = crate::name::mixed_script(name) {
148            self.issues.push(
149                ValidationIssue::warning(IssueCode::MixedScriptName, mixed.to_string())
150                    .with_suggestion(mixed.suggestion()),
151            );
152        }
153
154        if assign.path.segments.len() == 1 {
155            if let Some(dot) = name.find('.') {
156                let (root, rest) = (&name[..dot], &name[dot + 1..]);
157                self.issues.push(
158                    ValidationIssue::error(
159                        IssueCode::DottedAssignmentTarget,
160                        format!(
161                            "'{name}' is not a valid assignment target — kaish uses bracket \
162                             access, not dots"
163                        ),
164                    )
165                    .with_suggestion(format!("use `{root}[{rest}]=value`")),
166                );
167            }
168            if name.contains('#') {
169                // `#` is an ordinary word character, so `abc#3` is a legal
170                // word — but `$abc#3` is a lexer error, so this assignment
171                // would bind a variable that can never be read back. Silent
172                // creation of an unreachable name is worse than a refusal.
173                self.issues.push(
174                    ValidationIssue::error(
175                        IssueCode::UnreadableAssignmentTarget,
176                        format!(
177                            "'{name}' is not a valid assignment target — a variable name \
178                             cannot contain `#`"
179                        ),
180                    )
181                    .with_suggestion(format!(
182                        "drop the `#`, e.g. `{}=value`",
183                        name.replace('#', "_")
184                    )),
185                );
186            }
187            // Bind the variable name in scope
188            self.scope.bind(name);
189        } else if !self.scope.is_bound(name) {
190            self.issues.push(
191                ValidationIssue::error(
192                    IssueCode::LvalueUndefinedRoot,
193                    format!(
194                        "'{name}' is not defined — a subscripted assignment never creates the \
195                         root variable"
196                    ),
197                )
198                .with_suggestion(format!("create it first, e.g. `{name}={{}}` or `{name}=[]`")),
199            );
200            // Bind it anyway so a later reference to the same (still-invalid)
201            // root doesn't ALSO trigger a separate undefined-variable warning.
202            self.scope.bind(name);
203        }
204    }
205
206    /// Validate a command invocation.
207    fn validate_command(&mut self, cmd: &Command) {
208        // Skip source/. commands - they're dynamic
209        if cmd.name == "source" || cmd.name == "." {
210            return;
211        }
212
213        // Skip dynamic command names (variable expansions)
214        if !is_static_command_name(&cmd.name) {
215            return;
216        }
217
218        // Check if command exists
219        let is_builtin = self.registry.contains(&cmd.name);
220        let is_user_tool = self.user_tools.contains_key(&cmd.name);
221        let is_special = is_special_command(&cmd.name);
222
223        if !is_builtin && !is_user_tool && !is_special {
224            // Warning only - command might be a script in PATH or external tool.
225            // (`test` is now a first-class builtin — VFS-aware, validated — so it
226            // takes the `is_builtin` path above and never lands here.)
227            self.issues.push(ValidationIssue::warning(
228                IssueCode::UndefinedCommand,
229                format!("command '{}' not found in builtin registry", cmd.name),
230            ).with_suggestion("this may be a script in PATH or external command"));
231        }
232
233        // Validate arguments expressions
234        for arg in &cmd.args {
235            self.validate_arg(arg);
236        }
237
238        // If we have a schema, validate args against it. Pass the schema so the
239        // arg-builder binds glued/value short-flags the same way execute does —
240        // otherwise a tool whose validate() reads positionals semantically (sed,
241        // awk) misreads them (the schema-blind validation builder).
242        if let Some(tool) = self.registry.get(&cmd.name) {
243            // Prefer the kernel's schema catalog over `tool.schema()` — same
244            // rationale as the dispatch path in kernel.rs (GH #48/#254): for a
245            // clap-derived builtin, `schema()` rebuilds the whole clap
246            // `Command`, and the catalog already holds exactly that, name-sorted.
247            // `owned` covers a catalog miss (empty catalog, or a tool registered
248            // after the catalog was built): the fallback calls `schema()` and is
249            // equivalent, just not free.
250            let owned;
251            let schema: &ToolSchema =
252                match self.catalog.binary_search_by(|s| s.name.as_str().cmp(cmd.name.as_str())) {
253                    Ok(i) => &self.catalog[i],
254                    Err(_) => {
255                        owned = tool.schema();
256                        &owned
257                    }
258                };
259            let tool_args = build_tool_args_for_validation(&cmd.args, Some(schema));
260            let tool_issues = tool.validate(&tool_args);
261            self.issues.extend(tool_issues);
262        } else if let Some(user_tool) = self.user_tools.get(&cmd.name) {
263            // Validate against user-defined tool parameters
264            self.validate_user_tool_args(user_tool, &cmd.args);
265        }
266
267        // Validate redirects
268        for redirect in &cmd.redirects {
269            self.validate_expr(&redirect.target);
270        }
271    }
272
273    /// Validate a command argument.
274    fn validate_arg(&mut self, arg: &Arg) {
275        match arg {
276            Arg::Positional(expr) => self.validate_expr(expr),
277            Arg::Named { value, .. } => self.validate_expr(value),
278            Arg::WordAssign { value, .. } => self.validate_expr(value),
279            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
280        }
281    }
282
283    /// Validate a pipeline.
284    fn validate_pipeline(&mut self, pipe: &Pipeline) {
285        // Check for scatter without gather
286        let named = |name: &str| {
287            pipe.stages
288                .iter()
289                .filter_map(|s| s.as_command())
290                .any(|c| c.name == name)
291        };
292        let has_scatter = named("scatter");
293        let has_gather = named("gather");
294        if has_scatter && !has_gather {
295            self.issues.push(
296                ValidationIssue::error(
297                    IssueCode::ScatterWithoutGather,
298                    "scatter without gather — parallel results would be lost",
299                ).with_suggestion("add gather: ... | scatter | cmd | gather")
300            );
301        }
302
303        for stage in &pipe.stages {
304            match stage {
305                PipelineStage::Command(cmd) => self.validate_command(cmd),
306                PipelineStage::Compound(stmt) => self.validate_stmt(stmt),
307            }
308        }
309    }
310
311    /// Validate an if statement.
312    fn validate_if(&mut self, if_stmt: &IfStmt) {
313        self.validate_expr(&if_stmt.condition);
314
315        self.scope.push_frame();
316        for stmt in &if_stmt.then_branch {
317            self.validate_stmt(stmt);
318        }
319        self.scope.pop_frame();
320
321        if let Some(else_branch) = &if_stmt.else_branch {
322            self.scope.push_frame();
323            for stmt in else_branch {
324                self.validate_stmt(stmt);
325            }
326            self.scope.pop_frame();
327        }
328    }
329
330    /// Validate a for loop.
331    fn validate_for(&mut self, for_loop: &ForLoop) {
332        // Validate item expressions and check for bare scalar variables
333        for item in &for_loop.items {
334            self.validate_expr(item);
335
336            // Detect `for i in $VAR` pattern - always a mistake in kaish
337            // since we don't do implicit word splitting
338            if self.is_bare_scalar_var(item) {
339                self.issues.push(
340                    ValidationIssue::error(
341                        IssueCode::ForLoopScalarVar,
342                        "bare variable in for loop iterates once (kaish has no implicit word splitting)",
343                    )
344                    .with_suggestion(concat!(
345                        "wrap it in $(...) — for a collection use keys/values:\n",
346                        "    for x in $(values $coll)      # list elements / record values\n",
347                        "    for k in $(keys $coll)        # list indices / record keys\n",
348                        "    for i in $(split \"$VAR\")       # split a string on whitespace\n",
349                        "    for i in $(split \"$VAR\" \":\")   # split a string on a delimiter\n",
350                        "    for i in $(seq 1 10)          # iterate numbers\n",
351                        "    for i in $(glob \"*.rs\")        # iterate files",
352                    )),
353                );
354            }
355        }
356
357        self.loop_depth += 1;
358        self.scope.push_frame();
359
360        // Bind loop variable
361        self.scope.bind(&for_loop.variable);
362
363        for stmt in &for_loop.body {
364            self.validate_stmt(stmt);
365        }
366
367        self.scope.pop_frame();
368        self.loop_depth -= 1;
369    }
370
371    /// Extract glob pattern from unquoted literal expressions.
372    ///
373    /// Check if an expression is a bare scalar variable reference.
374    ///
375    /// Returns true for `$VAR` or `${VAR}` but not for `$(cmd)` or `"$VAR"`.
376    fn is_bare_scalar_var(&self, expr: &Expr) -> bool {
377        match expr {
378            // Direct variable reference like $VAR or ${VAR}
379            Expr::VarRef(_) => true,
380            // Variable with default like ${VAR:-default} - also problematic
381            Expr::VarWithDefault { .. } => true,
382            // NOT a problem: command substitution like $(cmd) - returns structured data
383            Expr::CommandSubst(_) => false,
384            // NOT a problem: literals are fine
385            Expr::Literal(_) => false,
386            // NOT a problem: interpolated strings are a single value
387            Expr::Interpolated(_) => false,
388            // Everything else: not a bare scalar var
389            _ => false,
390        }
391    }
392
393    /// Validate a while loop.
394    fn validate_while(&mut self, while_loop: &WhileLoop) {
395        self.validate_expr(&while_loop.condition);
396
397        self.loop_depth += 1;
398        self.scope.push_frame();
399
400        for stmt in &while_loop.body {
401            self.validate_stmt(stmt);
402        }
403
404        self.scope.pop_frame();
405        self.loop_depth -= 1;
406    }
407
408    /// Validate a case statement.
409    fn validate_case(&mut self, case_stmt: &CaseStmt) {
410        self.validate_expr(&case_stmt.expr);
411
412        for branch in &case_stmt.branches {
413            self.validate_case_branch(branch);
414        }
415    }
416
417    /// Validate a case branch.
418    fn validate_case_branch(&mut self, branch: &CaseBranch) {
419        self.scope.push_frame();
420        for stmt in &branch.body {
421            self.validate_stmt(stmt);
422        }
423        self.scope.pop_frame();
424    }
425
426    /// Validate a break statement.
427    fn validate_break(&mut self, levels: Option<usize>) {
428        if self.loop_depth == 0 {
429            self.issues.push(ValidationIssue::error(
430                IssueCode::BreakOutsideLoop,
431                "break used outside of a loop",
432            ));
433        } else if let Some(n) = levels
434            && n > self.loop_depth {
435                self.issues.push(ValidationIssue::warning(
436                    IssueCode::BreakOutsideLoop,
437                    format!(
438                        "break {} exceeds loop nesting depth {}",
439                        n, self.loop_depth
440                    ),
441                ));
442            }
443    }
444
445    /// Validate a continue statement.
446    fn validate_continue(&mut self, levels: Option<usize>) {
447        if self.loop_depth == 0 {
448            self.issues.push(ValidationIssue::error(
449                IssueCode::BreakOutsideLoop,
450                "continue used outside of a loop",
451            ));
452        } else if let Some(n) = levels
453            && n > self.loop_depth {
454                self.issues.push(ValidationIssue::warning(
455                    IssueCode::BreakOutsideLoop,
456                    format!(
457                        "continue {} exceeds loop nesting depth {}",
458                        n, self.loop_depth
459                    ),
460                ));
461            }
462    }
463
464    /// Validate a return statement.
465    fn validate_return(&mut self, expr: Option<&Expr>) {
466        if let Some(e) = expr {
467            self.validate_expr(e);
468        }
469
470        if self.function_depth == 0 {
471            self.issues.push(ValidationIssue::error(
472                IssueCode::ReturnOutsideFunction,
473                "return used outside of a function",
474            ));
475        }
476    }
477
478    /// Validate a tool definition.
479    fn validate_tool_def(&mut self, tool_def: &ToolDef) {
480        self.function_depth += 1;
481        self.scope.push_frame();
482
483        // Bind parameters
484        for param in &tool_def.params {
485            self.scope.bind(&param.name);
486            // Validate default expressions
487            if let Some(default) = &param.default {
488                self.validate_expr(default);
489            }
490        }
491
492        // Validate body
493        for stmt in &tool_def.body {
494            self.validate_stmt(stmt);
495        }
496
497        self.scope.pop_frame();
498        self.function_depth -= 1;
499    }
500
501    /// Validate a test expression.
502    fn validate_test(&mut self, test: &TestExpr) {
503        match test {
504            TestExpr::FileTest { path, .. } => self.validate_expr(path),
505            TestExpr::StringTest { value, .. } => self.validate_expr(value),
506            TestExpr::Comparison { left, right, .. } => {
507                self.validate_expr(left);
508                self.validate_expr(right);
509            }
510            TestExpr::And { left, right } | TestExpr::Or { left, right } => {
511                self.validate_test(left);
512                self.validate_test(right);
513            }
514            TestExpr::Not { expr } => self.validate_test(expr),
515            TestExpr::In { left, right } | TestExpr::NotIn { left, right } => {
516                self.validate_expr(left);
517                self.validate_expr(right);
518            }
519        }
520    }
521
522    /// Validate an expression.
523    fn validate_expr(&mut self, expr: &Expr) {
524        match expr {
525            Expr::Literal(_) => {}
526            Expr::VarRef(path) => self.validate_var_ref(path),
527            Expr::Interpolated(parts) => {
528                for part in parts {
529                    self.validate_string_part(part);
530                }
531            }
532            Expr::HereDocBody { parts, .. } => {
533                for sp in parts {
534                    self.validate_spanned_string_part(sp);
535                }
536            }
537            Expr::BinaryOp { left, right, .. } => {
538                self.validate_expr(left);
539                self.validate_expr(right);
540            }
541            Expr::CommandSubst(stmts) => {
542                for stmt in stmts {
543                    self.validate_stmt(stmt);
544                }
545            }
546            Expr::Test(test) => self.validate_test(test),
547            Expr::Positional(_) | Expr::AllArgs | Expr::ArgCount => {}
548            Expr::VarLength(path) => {
549                if let Some(VarSegment::Field(root)) = path.segments.first() {
550                    self.check_var_defined(root);
551                }
552            }
553            Expr::VarWithDefault { .. } => {
554                // Don't warn — the default handles the undefined/absent case.
555            }
556            Expr::Arithmetic(_) => {
557                // Arithmetic parsing is done at runtime
558            }
559            Expr::Command(cmd) => self.validate_command(cmd),
560            Expr::LastExitCode | Expr::CurrentPid => {}
561            Expr::GlobPattern(_) => {}
562            Expr::ListLiteral(elems) => {
563                for elem in elems {
564                    match elem {
565                        ListElem::Item(e) | ListElem::Spread(e) => self.validate_expr(e),
566                    }
567                }
568            }
569            Expr::RecordLiteral(entries) => {
570                for entry in entries {
571                    self.validate_expr(&entry.value);
572                }
573            }
574        }
575    }
576
577    /// Validate a variable reference.
578    fn validate_var_ref(&mut self, path: &VarPath) {
579        if let Some(VarSegment::Field(name)) = path.segments.first() {
580            // `${?.field}` is removed — $? is the POSIX integer exit code.
581            // Use `kaish-last` to access the previous command's structured data.
582            if name == "?" && path.segments.len() > 1 {
583                self.issues.push(
584                    ValidationIssue::error(
585                        IssueCode::LastResultFieldAccess,
586                        "${?.field} is removed; $? is the POSIX exit code",
587                    )
588                    .with_suggestion(
589                        "use `kaish-last` to read the previous command's data or stdout",
590                    ),
591                );
592                return;
593            }
594            self.check_var_defined(name);
595        }
596    }
597
598    /// Validate a spanned heredoc-body part, attaching the part's span to any
599    /// new issues raised during the inner walk. Issues already carrying a span
600    /// are left alone (e.g., from a nested validator that already knew better).
601    fn validate_spanned_string_part(&mut self, sp: &SpannedPart) {
602        let issues_before = self.issues.len();
603        self.validate_string_part(&sp.part);
604        let span = Span::new(sp.offset, sp.offset + sp.len);
605        for issue in &mut self.issues[issues_before..] {
606            if issue.span.is_none() {
607                issue.span = Some(span);
608            }
609        }
610    }
611
612    /// Validate a string interpolation part.
613    fn validate_string_part(&mut self, part: &StringPart) {
614        match part {
615            StringPart::Literal(_) => {}
616            StringPart::Var(path) => self.validate_var_ref(path),
617            StringPart::VarWithDefault { default, .. } => {
618                // Validate nested parts in the default value
619                for p in default {
620                    self.validate_string_part(p);
621                }
622            }
623            StringPart::VarLength(path) => {
624                if let Some(VarSegment::Field(root)) = path.segments.first() {
625                    self.check_var_defined(root);
626                }
627            }
628            StringPart::Positional(_) | StringPart::AllArgs | StringPart::ArgCount => {}
629            StringPart::Arithmetic(_) => {} // Arithmetic expressions are validated at eval time
630            StringPart::CommandSubst(stmts) => {
631                for stmt in stmts {
632                    self.validate_stmt(stmt);
633                }
634            }
635            StringPart::LastExitCode | StringPart::CurrentPid => {}
636        }
637    }
638
639    /// Check if a variable is defined and warn if not.
640    fn check_var_defined(&mut self, name: &str) {
641        // Skip underscore-prefixed vars (external/unchecked convention)
642        if ScopeTracker::should_skip_undefined_check(name) {
643            return;
644        }
645
646        if !self.scope.is_bound(name) {
647            self.issues.push(ValidationIssue::warning(
648                IssueCode::PossiblyUndefinedVariable,
649                format!("variable '{}' may be undefined", name),
650            ).with_suggestion(format!("use ${{{}:-default}} if this is intentional", name)));
651        }
652    }
653
654    /// Validate arguments against a user-defined tool's parameters.
655    ///
656    /// Counts bareword `key=value` (`Arg::WordAssign`) as positional: user
657    /// tools aren't on `WORD_ASSIGN_BUILTINS`, so at runtime the kernel
658    /// stringifies WordAssign into a positional `"key=value"` (matches bash).
659    /// Skipping it here would falsely error `mytool foo=bar` when mytool has
660    /// one required positional.
661    fn validate_user_tool_args(&mut self, tool_def: &ToolDef, args: &[Arg]) {
662        let positional_count = args
663            .iter()
664            .filter(|a| matches!(a, Arg::Positional(_) | Arg::WordAssign { .. }))
665            .count();
666
667        let required_count = tool_def
668            .params
669            .iter()
670            .filter(|p| p.default.is_none())
671            .count();
672
673        if positional_count < required_count {
674            self.issues.push(ValidationIssue::error(
675                IssueCode::MissingRequiredArg,
676                format!(
677                    "'{}' requires {} arguments, got {}",
678                    tool_def.name, required_count, positional_count
679                ),
680            ));
681        }
682    }
683}
684
685/// Check if a command name is static (not a variable expansion).
686///
687/// The parser only ever produces a literal `Command.name`, so for the AST-walk
688/// path this is always true; the `${…}` / `$(…)` guards matter for the
689/// string-accepting `Kernel::classify_command` API, where a dynamic name
690/// classifies as [`CommandKind::Dynamic`] rather than a misleading `External`.
691pub(crate) fn is_static_command_name(name: &str) -> bool {
692    !name.starts_with('$') && !name.contains("$(") && !name.contains("${")
693}
694
695/// Interpreter special-forms — the command names `execute_command_depth`
696/// short-circuits before any alias/registry/`PATH` lookup.
697///
698/// **Single source of truth, compile-enforced.** `from_name` is the only place a
699/// name becomes "special", and the executor matches this enum *exhaustively*, so
700/// adding a form is a compile error until both the name mapping here and the
701/// execution behavior in `execute_command_depth` are updated — the two cannot
702/// silently diverge, and there's no `unreachable!` to panic at runtime.
703/// `classify_command` reports every special form as `CommandKind::Special` via
704/// [`is_runtime_special_form`].
705///
706/// This set is deliberately **narrower** than `is_special_command` below: that
707/// set is a validator warning heuristic (it suppresses "command not found" for
708/// names like `readonly`/`:` that the validator chooses not to flag), whereas
709/// this reflects what the interpreter *actually* short-circuits. Keeping them
710/// separate avoids `classify_command` mislabelling a name as internal when it
711/// would in fact escape to `PATH` (e.g. `readonly` resolves to an external
712/// command at runtime). See `Kernel::classify_command`.
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub(crate) enum SpecialForm {
715    /// `true` and its other spelling `:` — always succeed (exit 0).
716    True,
717    /// `false` — always fails (exit 1).
718    False,
719    /// `source` / `.` — execute a script in the current shell.
720    Source,
721}
722
723impl SpecialForm {
724    /// The single mapping from a command name to a special-form, or `None` if the
725    /// name is resolved normally (alias/registry/`PATH`).
726    pub(crate) fn from_name(name: &str) -> Option<Self> {
727        match name {
728            // `:` short-circuits through the same arm as `true`, not through
729            // its registered tool. That is what makes them the SAME command
730            // rather than two that merely agree: an alias cannot shadow
731            // either, and neither clap-parses its operands, so `: -x` exits 0
732            // like bash instead of failing on an unknown flag.
733            "true" | ":" => Some(Self::True),
734            "false" => Some(Self::False),
735            "source" | "." => Some(Self::Source),
736            _ => None,
737        }
738    }
739}
740
741/// Whether `name` is an interpreter special-form (see [`SpecialForm`]).
742pub(crate) fn is_runtime_special_form(name: &str) -> bool {
743    SpecialForm::from_name(name).is_some()
744}
745
746/// Classify a command name the way the interpreter resolves it, given whether
747/// the registry and user-tool table contain it. Shared by the validator's
748/// triage and `Kernel::classify_command` so the two never diverge.
749pub(crate) fn classify_command_name(
750    name: &str,
751    is_builtin: bool,
752    is_user_tool: bool,
753) -> CommandKind {
754    if !is_static_command_name(name) {
755        return CommandKind::Dynamic;
756    }
757    if is_runtime_special_form(name) {
758        return CommandKind::Special;
759    }
760    // User functions are checked before builtins in `execute_command_depth`, so
761    // a user function shadows a builtin of the same name.
762    if is_user_tool {
763        return CommandKind::UserTool;
764    }
765    if is_builtin {
766        return CommandKind::Builtin;
767    }
768    CommandKind::External
769}
770
771/// Check if a command is a special built-in that we don't validate.
772fn is_special_command(name: &str) -> bool {
773    // `test`/`[`/`[[`/`:` are intentionally absent: `test` and `:` are real
774    // builtins now (they validate via the registry like any other), and
775    // `[`/`[[` parse as `[[ … ]]` test expressions, never reaching here as a
776    // command name.
777    matches!(name, "true" | "false" | "readonly" | "local")
778}
779
780/// Build ToolArgs from AST Args for validation purposes.
781///
782/// This is a simplified version that doesn't evaluate expressions -
783/// it uses placeholder values since we only care about argument structure.
784pub fn build_tool_args_for_validation(args: &[Arg], schema: Option<&ToolSchema>) -> ToolArgs {
785    let mut tool_args = ToolArgs::new();
786    // Schema-aware param table: flag name → (canonical, type, consumes, repeatable).
787    // Empty when there's no schema, in which case every flag stays a bare flag
788    // (the old schema-blind behavior).
789    let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
790    let mut consumed: HashSet<usize> = HashSet::new();
791    let mut past_double_dash = false;
792
793    for i in 0..args.len() {
794        match &args[i] {
795            Arg::DoubleDash => past_double_dash = true,
796            Arg::Positional(expr) => {
797                if !consumed.contains(&i) {
798                    tool_args.positional.push(expr_to_placeholder(expr));
799                }
800            }
801            Arg::Named { key, value } => {
802                let v = expr_to_placeholder(value);
803                match param_lookup.get(key.as_str()) {
804                    // Repeatable `--flag=a --flag=b` accumulates (matches execute).
805                    Some(&(canonical, _, _, true)) => {
806                        let _ = push_repeatable_value(&mut tool_args, key, canonical, v);
807                    }
808                    Some(&(canonical, ..)) => {
809                        tool_args.named.insert(canonical.to_string(), v);
810                    }
811                    None => {
812                        tool_args.named.insert(key.clone(), v);
813                    }
814                }
815            }
816            Arg::WordAssign { key, value } => {
817                // Validation walker doesn't know which command is receiving;
818                // route into named like the legacy behavior so checks stay
819                // consistent with previous validator output.
820                tool_args.named.insert(key.clone(), expr_to_placeholder(value));
821            }
822            Arg::ShortFlag(name) => {
823                if past_double_dash {
824                    tool_args.positional.push(Value::String(format!("-{name}")));
825                } else {
826                    bind_short_flag_for_validation(
827                        name,
828                        &param_lookup,
829                        args,
830                        i,
831                        &mut consumed,
832                        &mut tool_args,
833                    );
834                }
835            }
836            Arg::LongFlag(name) => {
837                if past_double_dash {
838                    tool_args.positional.push(Value::String(format!("--{name}")));
839                } else {
840                    match param_lookup.get(name.as_str()) {
841                        Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
842                            bind_value_or_flag(
843                                &mut tool_args, name, canonical, consumes, repeatable, args, i,
844                                &mut consumed,
845                            );
846                        }
847                        Some(&(canonical, ..)) => {
848                            tool_args.flags.insert(canonical.to_string());
849                        }
850                        None => {
851                            tool_args.flags.insert(name.clone());
852                        }
853                    }
854                }
855            }
856        }
857    }
858
859    tool_args
860}
861
862/// Bind a (possibly glued/combined) short-flag token, schema-aware, mirroring
863/// `kernel::build_args_async`: a value-taking first char consumes the rest of the
864/// token as its glued value (`-e1d` → e=`1d`) or, if it is the last char, the next
865/// positional (`-e d` → e=`d`); bool flags stack (`-la`). Unknown chars stay bare
866/// flags so a schemaless tool keeps all-boolean behavior.
867fn bind_short_flag_for_validation(
868    name: &str,
869    param_lookup: &HashMap<String, (&str, &str, usize, bool)>,
870    args: &[Arg],
871    i: usize,
872    consumed: &mut HashSet<usize>,
873    tool_args: &mut ToolArgs,
874) {
875    // Whole-name match first (POSIX `-name value` or a multi-char bool).
876    if let Some(&(canonical, typ, consumes, repeatable)) = param_lookup.get(name) {
877        if is_bool_type(typ) {
878            tool_args.flags.insert(canonical.to_string());
879        } else {
880            bind_value_or_flag(tool_args, name, canonical, consumes, repeatable, args, i, consumed);
881        }
882        return;
883    }
884    // First char is a declared value-taking short flag: the tail is its glued value.
885    if let Some(&(canonical, _, consumes, repeatable)) = param_lookup
886        .get(&name[..1])
887        .filter(|(_, typ, ..)| !is_bool_type(typ))
888    {
889        let glued = name[1..].to_string();
890        if glued.is_empty() {
891            bind_value_or_flag(
892                tool_args, &name[..1], canonical, consumes, repeatable, args, i, consumed,
893            );
894        } else {
895            let _ =
896                bind_glued_short_value(tool_args, &name[..1], canonical, consumes, repeatable, glued);
897        }
898        return;
899    }
900    // Combined short flags: bools stack until the first value-taking char, which
901    // consumes the rest of the token (or the next positional).
902    let bytes = name.as_bytes();
903    let mut p = 0;
904    while p < bytes.len() {
905        let key = &name[p..p + 1];
906        match param_lookup.get(key) {
907            Some(&(canonical, typ, consumes, repeatable)) if !is_bool_type(typ) => {
908                let glued = name[p + 1..].to_string();
909                if glued.is_empty() {
910                    bind_value_or_flag(
911                        tool_args, key, canonical, consumes, repeatable, args, i, consumed,
912                    );
913                } else {
914                    let _ = bind_glued_short_value(
915                        tool_args, key, canonical, consumes, repeatable, glued,
916                    );
917                }
918                return;
919            }
920            _ => {
921                tool_args.flags.insert(key.to_string());
922                p += 1;
923            }
924        }
925    }
926}
927
928/// Bind a bare value-flag by consuming the next `consumes` not-yet-consumed
929/// positionals as its value(s) — mirroring `kernel::consume_flag_positionals`:
930/// a single-value flag stores a scalar (repeatable → canonical array), a
931/// multi-value flag (`jq --arg NAME VALUE`, `consumes==2`) stores an
932/// array-of-arrays. A single-value flag may also consume a `key=value`
933/// (`awk -v a=1`); multi-value flags take plain positionals only. With nothing
934/// to consume it falls back to a bare flag.
935#[allow(clippy::too_many_arguments)] // mirrors kernel::consume_flag_positionals
936fn bind_value_or_flag(
937    tool_args: &mut ToolArgs,
938    flag_name: &str,
939    canonical: &str,
940    consumes: usize,
941    repeatable: bool,
942    args: &[Arg],
943    i: usize,
944    consumed: &mut HashSet<usize>,
945) {
946    let want = consumes.max(1);
947    let allow_word_assign = consumes <= 1;
948    let mut collected: Vec<Value> = Vec::with_capacity(want);
949    for _ in 0..want {
950        let found = args[i + 1..].iter().enumerate().find_map(|(off, a)| {
951            let idx = i + 1 + off;
952            if consumed.contains(&idx) {
953                return None;
954            }
955            match a {
956                Arg::Positional(expr) => Some((idx, expr_to_placeholder(expr))),
957                Arg::WordAssign { key, value } if allow_word_assign => {
958                    let s = crate::interpreter::value_to_string(&expr_to_placeholder(value));
959                    Some((idx, Value::String(format!("{key}={s}"))))
960                }
961                _ => None,
962            }
963        });
964        match found {
965            Some((idx, v)) => {
966                consumed.insert(idx);
967                collected.push(v);
968            }
969            None => break,
970        }
971    }
972
973    if collected.is_empty() {
974        tool_args.flags.insert(canonical.to_string());
975        return;
976    }
977    if consumes <= 1 {
978        if let Some(v) = collected.into_iter().next() {
979            if repeatable {
980                // Discarding this error is safe ONLY because no value reaching
981                // here can be binary. `expr_to_placeholder` passes literals
982                // through and renders everything dynamic as `<dynamic>`, and
983                // the parser cannot build a `Value::Bytes` literal — bytes only
984                // arise at runtime, from a capture the validator never runs. So
985                // the binder's binary gate (`flag_value_to_json`, GH #223)
986                // cannot fire here, and the only other error is the
987                // non-array-entry case, which a fresh `tool_args` cannot reach.
988                // Give the validator real evaluated values and this line starts
989                // swallowing that gate — propagate it then.
990                let _ = push_repeatable_value(tool_args, flag_name, canonical, v);
991            } else {
992                tool_args.named.insert(canonical.to_string(), v);
993            }
994        }
995        return;
996    }
997    // Multi-consume: accumulate under named[canonical] as array-of-arrays.
998    // Deliberately the raw `value_to_json`, not the binder's `flag_value_to_json`:
999    // this is a validation-only twin over placeholders, so there is no binary to
1000    // gate and nothing to report. Same caveat as above — it diverges from the
1001    // binder the moment the validator evaluates for real.
1002    let occ: Vec<serde_json::Value> = collected
1003        .iter()
1004        .map(crate::interpreter::value_to_json)
1005        .collect();
1006    let entry = tool_args
1007        .named
1008        .entry(canonical.to_string())
1009        .or_insert_with(|| Value::Json(serde_json::Value::Array(Vec::new())));
1010    if let Value::Json(serde_json::Value::Array(outer)) = entry {
1011        outer.push(serde_json::Value::Array(occ));
1012    }
1013}
1014
1015/// Convert an expression to a placeholder value for validation.
1016///
1017/// For literal values, return the actual value.
1018/// For dynamic expressions (var refs, command subst), return a placeholder.
1019fn expr_to_placeholder(expr: &Expr) -> Value {
1020    match expr {
1021        Expr::Literal(val) => val.clone(),
1022        Expr::Interpolated(parts) if parts.len() == 1 => {
1023            if let StringPart::Literal(s) = &parts[0] {
1024                Value::String(s.clone())
1025            } else {
1026                Value::String("<dynamic>".to_string())
1027            }
1028        }
1029        // For variable refs, command substitution, etc. - use placeholder
1030        _ => Value::String("<dynamic>".to_string()),
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use crate::tools::{register_builtins, ToolRegistry};
1038
1039    fn make_validator() -> (ToolRegistry, HashMap<String, ToolDef>) {
1040        let mut registry = ToolRegistry::new();
1041        register_builtins(&mut registry);
1042        let user_tools = HashMap::new();
1043        (registry, user_tools)
1044    }
1045
1046    #[test]
1047    fn validates_undefined_command() {
1048        let (registry, user_tools) = make_validator();
1049        let validator = Validator::new(&registry, &user_tools, &[]);
1050
1051        let program = Program {
1052            statements: vec![Stmt::Command(Command {
1053                name: "nonexistent_command".to_string(),
1054                args: vec![],
1055                redirects: vec![],
1056            })],
1057        };
1058
1059        let issues = validator.validate(&program);
1060        assert!(!issues.is_empty());
1061        assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1062    }
1063
1064    /// `test` is a first-class builtin now, so it validates through the
1065    /// registry like any other command — no POSIX-conditional advisory, and no
1066    /// spurious undefined-command warning.
1067    #[test]
1068    fn test_command_is_a_known_builtin() {
1069        let (registry, user_tools) = make_validator();
1070        let validator = Validator::new(&registry, &user_tools, &[]);
1071
1072        let program = Program {
1073            statements: vec![Stmt::Command(Command {
1074                name: "test".to_string(),
1075                args: vec![
1076                    Arg::Positional(Expr::Literal(Value::String("-n".to_string()))),
1077                    Arg::Positional(Expr::Literal(Value::String("hi".to_string()))),
1078                ],
1079                redirects: vec![],
1080            })],
1081        };
1082
1083        let issues = validator.validate(&program);
1084        assert!(
1085            !issues.iter().any(|i| i.code == IssueCode::UndefinedCommand),
1086            "`test` is a builtin — no undefined-command warning: {issues:?}"
1087        );
1088    }
1089
1090    #[test]
1091    fn validates_known_command() {
1092        let (registry, user_tools) = make_validator();
1093        let validator = Validator::new(&registry, &user_tools, &[]);
1094
1095        let program = Program {
1096            statements: vec![Stmt::Command(Command {
1097                name: "echo".to_string(),
1098                args: vec![Arg::Positional(Expr::Literal(Value::String(
1099                    "hello".to_string(),
1100                )))],
1101                redirects: vec![],
1102            })],
1103        };
1104
1105        let issues = validator.validate(&program);
1106        // echo should not produce an undefined command error
1107        assert!(!issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
1108    }
1109
1110    /// GH #256's whole premise is that reading the catalog instead of calling
1111    /// `tool.schema()` doesn't change a single validation outcome. Every other
1112    /// test in this module passes `&[]` and only ever exercises the fallback
1113    /// (`tool.schema()`) branch of `validate_command`'s binary search — this
1114    /// is the one that actually populates the catalog and hits it, then
1115    /// checks the two branches agree byte-for-byte on a schema-driven issue.
1116    ///
1117    /// `jq`'s `filter` is the only *required* param anywhere in the builtin
1118    /// catalog (checked by dumping every schema's `required` params while
1119    /// writing this test) and jq's own `Tool::validate` override calls
1120    /// `validate_against_schema(args, &self.schema())` first thing — so
1121    /// `jq` with no args at all is guaranteed to trip `MissingRequiredArg`
1122    /// via a real schema check, identically on both paths, with no need to
1123    /// fabricate a corrupt schema to make it non-trivial.
1124    ///
1125    /// This was manually verified to be a *real*, falsifiable comparison, not
1126    /// a vacuous one: temporarily corrupting the catalog's `jq` entry so `-r`
1127    /// (a genuine bool flag) is misdeclared as value-consuming, and changing
1128    /// the program to `jq -r .` (filter present), makes the two paths
1129    /// disagree — the catalog-hit path swallows the filter positional `.` as
1130    /// `-r`'s value and reports `MissingRequiredArg`, while the fallback path
1131    /// (real schema, `-r` correctly bool) does not — and the `assert_eq!`
1132    /// below fails as expected. Not committed as a permanent test (it would
1133    /// pin a corruption, not a correctness invariant); this docstring is the
1134    /// record of having done it.
1135    #[test]
1136    fn catalog_hit_and_fallback_produce_identical_schema_driven_issues() {
1137        let (registry, user_tools) = make_validator();
1138        let catalog = registry.schemas();
1139        assert!(
1140            catalog.binary_search_by(|s| s.name.as_str().cmp("jq")).is_ok(),
1141            "fixture assumption: `jq` must be in the catalog for this to be a real hit"
1142        );
1143
1144        let program = Program {
1145            statements: vec![Stmt::Command(Command {
1146                name: "jq".to_string(),
1147                args: vec![],
1148                redirects: vec![],
1149            })],
1150        };
1151
1152        let fallback_issues =
1153            Validator::new(&registry, &user_tools, &[]).validate(&program);
1154        let catalog_issues =
1155            Validator::new(&registry, &user_tools, &catalog).validate(&program);
1156
1157        // Prove the input is actually discriminating: a program both paths
1158        // report zero issues on proves nothing about whether they agree.
1159        assert!(
1160            fallback_issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1161            "test input should trip MissingRequiredArg (`filter`) via the fallback path; \
1162             got {fallback_issues:?}"
1163        );
1164
1165        fn render(issues: &[ValidationIssue]) -> Vec<(Severity, IssueCode, &str, Option<&str>)> {
1166            issues
1167                .iter()
1168                .map(|i| (i.severity, i.code, i.message.as_str(), i.suggestion.as_deref()))
1169                .collect()
1170        }
1171        assert_eq!(
1172            render(&fallback_issues),
1173            render(&catalog_issues),
1174            "catalog-hit and tool.schema()-fallback validation must agree exactly \
1175             (same codes, same messages, same order); fallback={fallback_issues:?} \
1176             catalog={catalog_issues:?}"
1177        );
1178    }
1179
1180    #[test]
1181    fn glued_value_flags_dont_false_error_at_validation() {
1182        // Regression for the schema-blind validation builder:
1183        // `sed -e1d -e2d FILE` must validate clean. Before schema-aware binding,
1184        // the glued `-e` values weren't split, so `collect_expressions` fell back
1185        // to parsing the FILE PATH as the sed program — a path-dependent false
1186        // E006 (here `file.txt` → its leading `f` is an "unknown command").
1187        let (registry, user_tools) = make_validator();
1188        let validator = Validator::new(&registry, &user_tools, &[]);
1189
1190        let program = Program {
1191            statements: vec![Stmt::Command(Command {
1192                name: "sed".to_string(),
1193                args: vec![
1194                    Arg::ShortFlag("e1d".to_string()),
1195                    Arg::ShortFlag("e2d".to_string()),
1196                    Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1197                ],
1198                redirects: vec![],
1199            })],
1200        };
1201
1202        let issues = validator.validate(&program);
1203        assert!(
1204            !issues.iter().any(|i| i.code == IssueCode::InvalidSedExpr),
1205            "glued -e flags false-errored at validation: {:?}",
1206            issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1207        );
1208    }
1209
1210    #[test]
1211    fn validates_break_outside_loop() {
1212        let (registry, user_tools) = make_validator();
1213        let validator = Validator::new(&registry, &user_tools, &[]);
1214
1215        let program = Program {
1216            statements: vec![Stmt::Break(None)],
1217        };
1218
1219        let issues = validator.validate(&program);
1220        assert!(issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1221    }
1222
1223    #[test]
1224    fn validates_break_inside_loop() {
1225        let (registry, user_tools) = make_validator();
1226        let validator = Validator::new(&registry, &user_tools, &[]);
1227
1228        let program = Program {
1229            statements: vec![Stmt::For(ForLoop {
1230                variable: "i".to_string(),
1231                items: vec![Expr::Literal(Value::String("1 2 3".to_string()))],
1232                body: vec![Stmt::Break(None)],
1233            })],
1234        };
1235
1236        let issues = validator.validate(&program);
1237        // Break inside loop should NOT produce an error
1238        assert!(!issues.iter().any(|i| i.code == IssueCode::BreakOutsideLoop));
1239    }
1240
1241    #[test]
1242    fn validates_undefined_variable() {
1243        let (registry, user_tools) = make_validator();
1244        let validator = Validator::new(&registry, &user_tools, &[]);
1245
1246        let program = Program {
1247            statements: vec![Stmt::Command(Command {
1248                name: "echo".to_string(),
1249                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple(
1250                    "UNDEFINED_VAR",
1251                )))],
1252                redirects: vec![],
1253            })],
1254        };
1255
1256        let issues = validator.validate(&program);
1257        assert!(issues
1258            .iter()
1259            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1260    }
1261
1262    #[test]
1263    fn validates_defined_variable() {
1264        let (registry, user_tools) = make_validator();
1265        let validator = Validator::new(&registry, &user_tools, &[]);
1266
1267        let program = Program {
1268            statements: vec![
1269                // First assign the variable
1270                Stmt::Assignment(Assignment {
1271                    path: VarPath::simple("MY_VAR"),
1272                    value: Expr::Literal(Value::String("value".to_string())),
1273                    local: false,
1274                }),
1275                // Then use it
1276                Stmt::Command(Command {
1277                    name: "echo".to_string(),
1278                    args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("MY_VAR")))],
1279                    redirects: vec![],
1280                }),
1281            ],
1282        };
1283
1284        let issues = validator.validate(&program);
1285        // Should NOT warn about MY_VAR
1286        assert!(!issues
1287            .iter()
1288            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable
1289                && i.message.contains("MY_VAR")));
1290    }
1291
1292    #[test]
1293    fn skips_underscore_prefixed_vars() {
1294        let (registry, user_tools) = make_validator();
1295        let validator = Validator::new(&registry, &user_tools, &[]);
1296
1297        let program = Program {
1298            statements: vec![Stmt::Command(Command {
1299                name: "echo".to_string(),
1300                args: vec![Arg::Positional(Expr::VarRef(VarPath::simple("_EXTERNAL")))],
1301                redirects: vec![],
1302            })],
1303        };
1304
1305        let issues = validator.validate(&program);
1306        // Should NOT warn about _EXTERNAL
1307        assert!(!issues
1308            .iter()
1309            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1310    }
1311
1312    #[test]
1313    fn builtin_vars_are_defined() {
1314        let (registry, user_tools) = make_validator();
1315        let validator = Validator::new(&registry, &user_tools, &[]);
1316
1317        let program = Program {
1318            statements: vec![Stmt::Command(Command {
1319                name: "echo".to_string(),
1320                args: vec![
1321                    Arg::Positional(Expr::VarRef(VarPath::simple("HOME"))),
1322                    Arg::Positional(Expr::VarRef(VarPath::simple("PATH"))),
1323                    Arg::Positional(Expr::VarRef(VarPath::simple("PWD"))),
1324                ],
1325                redirects: vec![],
1326            })],
1327        };
1328
1329        let issues = validator.validate(&program);
1330        // Should NOT warn about HOME, PATH, PWD
1331        assert!(!issues
1332            .iter()
1333            .any(|i| i.code == IssueCode::PossiblyUndefinedVariable));
1334    }
1335
1336    #[test]
1337    fn validates_scatter_without_gather() {
1338        let (registry, user_tools) = make_validator();
1339        let validator = Validator::new(&registry, &user_tools, &[]);
1340
1341        let program = Program {
1342            statements: vec![Stmt::Pipeline(Pipeline {
1343                stages: vec![
1344                    Command { name: "seq".to_string(), args: vec![
1345                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
1346                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
1347                    ], redirects: vec![] },
1348                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1349                    Command { name: "echo".to_string(), args: vec![
1350                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1351                    ], redirects: vec![] },
1352                ]
1353                .into_iter()
1354                .map(PipelineStage::Command)
1355                .collect(),
1356                background: false,
1357            })],
1358        };
1359
1360        let issues = validator.validate(&program);
1361        assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1362            "should flag scatter without gather: {:?}", issues);
1363    }
1364
1365    #[test]
1366    fn allows_scatter_with_gather() {
1367        let (registry, user_tools) = make_validator();
1368        let validator = Validator::new(&registry, &user_tools, &[]);
1369
1370        let program = Program {
1371            statements: vec![Stmt::Pipeline(Pipeline {
1372                stages: vec![
1373                    Command { name: "seq".to_string(), args: vec![
1374                        Arg::Positional(Expr::Literal(Value::String("1".into()))),
1375                        Arg::Positional(Expr::Literal(Value::String("3".into()))),
1376                    ], redirects: vec![] },
1377                    Command { name: "scatter".to_string(), args: vec![], redirects: vec![] },
1378                    Command { name: "echo".to_string(), args: vec![
1379                        Arg::Positional(Expr::Literal(Value::String("hi".into()))),
1380                    ], redirects: vec![] },
1381                    Command { name: "gather".to_string(), args: vec![], redirects: vec![] },
1382                ]
1383                .into_iter()
1384                .map(PipelineStage::Command)
1385                .collect(),
1386                background: false,
1387            })],
1388        };
1389
1390        let issues = validator.validate(&program);
1391        assert!(!issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
1392            "scatter with gather should pass: {:?}", issues);
1393    }
1394
1395    fn make_user_tool_with_required_positional() -> HashMap<String, ToolDef> {
1396        let mut user_tools = HashMap::new();
1397        user_tools.insert(
1398            "mytool".to_string(),
1399            ToolDef {
1400                name: "mytool".to_string(),
1401                params: vec![crate::ast::ParamDef {
1402                    name: "input".to_string(),
1403                    param_type: None,
1404                    default: None,
1405                }],
1406                body: vec![],
1407            },
1408        );
1409        user_tools
1410    }
1411
1412    /// `mytool foo=bar` should count the bareword `key=value` as a positional
1413    /// because user tools aren't on the WordAssign allowlist — runtime
1414    /// stringifies WordAssign to a positional. Validator must agree.
1415    #[test]
1416    fn user_tool_wordassign_counts_as_positional() {
1417        let mut registry = ToolRegistry::new();
1418        register_builtins(&mut registry);
1419        let user_tools = make_user_tool_with_required_positional();
1420        let validator = Validator::new(&registry, &user_tools, &[]);
1421
1422        let program = Program {
1423            statements: vec![Stmt::Command(Command {
1424                name: "mytool".to_string(),
1425                args: vec![Arg::WordAssign {
1426                    key: "foo".to_string(),
1427                    value: Expr::Literal(Value::String("bar".to_string())),
1428                }],
1429                redirects: vec![],
1430            })],
1431        };
1432
1433        let issues = validator.validate(&program);
1434        assert!(
1435            !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1436            "WordAssign should satisfy required positional; got {:?}",
1437            issues
1438        );
1439    }
1440
1441    /// Missing-required-arg still fires when no positional or WordAssign is
1442    /// provided — regression guard so the fix doesn't silently skip the check.
1443    #[test]
1444    fn user_tool_no_args_still_errors() {
1445        let mut registry = ToolRegistry::new();
1446        register_builtins(&mut registry);
1447        let user_tools = make_user_tool_with_required_positional();
1448        let validator = Validator::new(&registry, &user_tools, &[]);
1449
1450        let program = Program {
1451            statements: vec![Stmt::Command(Command {
1452                name: "mytool".to_string(),
1453                args: vec![],
1454                redirects: vec![],
1455            })],
1456        };
1457
1458        let issues = validator.validate(&program);
1459        assert!(
1460            issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
1461            "missing positional should still error; got {:?}",
1462            issues
1463        );
1464    }
1465}