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