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