Skip to main content

golem_schema/schema/tool/validation/
mod.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Producer-side construction invariants for the tool metadata model.
16//!
17//! The WIT shape alone does not enforce the construction invariants listed in
18//! the `golem:tool/common@0.1.0` doc-comment; they are checked here by
19//! [`validate_tool`]. These checks are intended to run when a tool component's
20//! metadata is produced or first ingested, so that every downstream consumer
21//! (signature projection, help rendering, the runtime) can assume a
22//! well-formed [`Tool`].
23//!
24//! Type and value well-formedness (recursion, named-definition resolution,
25//! value-against-type validity) is delegated to the shared `golem:core/types`
26//! model in [`golem_schema`]: typed positions embed a recursive
27//! [`SchemaType`] resolving against the tool's [`SchemaGraph`], and
28//! [`validate_value`] checks `value-is` literals against the declared type.
29//!
30//! The CLI-structural checks implemented here are:
31//!
32//!   * The command tree is non-empty (the root command is at index 0).
33//!   * Every `command-index` subcommand reference is in bounds.
34//!   * All identifier-like strings match [`is_valid_identifier`].
35//!   * Subcommand names and aliases are pairwise unique among siblings.
36//!   * Within a `command-body`, option/flag long names, their aliases,
37//!     positional names, and short forms are pairwise unique, and unique
38//!     against globals inherited from the command itself and any ancestor.
39//!   * Every constraint `ref` name resolves to a body-declared or inherited
40//!     option/flag/positional.
41//!   * Each `value-is` literal is a valid value for the declared type of the
42//!     referenced name (with the "any element/occurrence" relaxation for
43//!     list-typed and repeatable names).
44//!   * A body's `default-formatter` resolves to one of its `formatters`.
45//!   * A `tail-positional` with `verbatim = true` declares a `separator`.
46//!   * `variant` types never appear in (or are reachable from) an input
47//!     position; input-side discrimination uses `union` instead.
48//!   * The command tree is an acyclic tree: every node is reachable from the
49//!     root and has exactly one parent (no cycles, no shared subcommands).
50//!
51//! One WIT-listed invariant is deliberately not checked here: the root
52//! command-node's name equals the tool's metadata name. A tool is identified by
53//! its root command name, so there is no second value to compare against at
54//! this layer; the check belongs to whatever registers a tool under an
55//! externally supplied name.
56
57use super::{
58    CommandBody, CommandIndex, CommandNode, Constraint, Globals, OptionShape, OptionSpec,
59    Positional, Ref, Tool, ToolMiddleware, ToolMiddlewareScope,
60};
61use crate::schema::graph::{RefResolutionError, SchemaGraph};
62use crate::schema::metadata::TypeId;
63use crate::schema::schema_type::SchemaType;
64use crate::schema::schema_value::SchemaValue;
65use crate::schema::validation::value::{ValueError, validate_value};
66use crate::schema::validation::well_formedness::{SchemaError, validate_graph, validate_root_type};
67use std::collections::HashSet;
68use std::fmt::{self, Display, Formatter};
69
70/// Returns `true` if `s` is a valid tool identifier (`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`).
71pub fn is_valid_identifier(s: &str) -> bool {
72    s.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
73        && s.split('-').all(|part| {
74            !part.is_empty()
75                && part
76                    .bytes()
77                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
78        })
79}
80
81/// A single producer-side construction-invariant violation.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub enum ToolValidationError {
84    /// A tool version must contain at least one non-whitespace character.
85    EmptyVersion,
86    /// The command tree has no nodes (it must contain at least the root).
87    EmptyCommandTree,
88    /// A `command-index` references a node outside the command tree.
89    CommandIndexOutOfBounds { index: i32, len: usize },
90    /// An identifier-like string does not match [`is_valid_identifier`].
91    InvalidIdentifier { kind: &'static str, value: String },
92    /// Two sibling subcommands share a name or alias.
93    DuplicateSubcommandName { parent: String, name: String },
94    /// Two names (option/flag long names, aliases, positional names) collide
95    /// within a command body's scope (including inherited globals).
96    DuplicateName { command: String, name: String },
97    /// Two short forms collide within a command body's scope.
98    DuplicateShort { command: String, short: char },
99    /// A constraint `ref` names something not in scope.
100    UnresolvedConstraintRef { command: String, name: String },
101    /// A `value-is` literal is not a valid value for the referenced name's
102    /// declared type.
103    ValueIsTypeMismatch { command: String, name: String },
104    /// A `default` literal is not a valid value for its option/positional's
105    /// declared type (a `repeatable` option's default must be a list of its
106    /// element type).
107    DefaultTypeMismatch { command: String, name: String },
108    /// A `repeatable-map` option's collected `map_type` does not resolve to a
109    /// [`SchemaType::Map`] (the collected key-value value must be a map node).
110    RepeatableMapTypeNotMap { command: String, name: String },
111    /// An embedded type contains a [`SchemaType::Ref`] whose id does not
112    /// resolve to a definition in the tool's [`SchemaGraph`].
113    UnresolvedTypeRef {
114        command: String,
115        position: String,
116        id: String,
117    },
118    /// An embedded type is not structurally well-formed (for example an
119    /// inverted numeric `min > max`, an empty variant, or a non-primitive map
120    /// key), as reported by schema well-formedness validation.
121    IllFormedSchema {
122        command: String,
123        position: String,
124        detail: String,
125    },
126    /// Two definitions in the tool's [`SchemaGraph`] share the same type id; the
127    /// wire encoder requires definition ids to be unique.
128    DuplicateTypeId { id: String },
129    /// A body's `default-formatter` does not resolve to one of its formatters.
130    UnresolvedDefaultFormatter { command: String, formatter: String },
131    /// A `tail-positional` declares `verbatim = true` without a `separator`.
132    VerbatimWithoutSeparator { command: String, positional: String },
133    /// A `variant` type is reachable from an input position; input-side
134    /// discrimination must use `union` instead.
135    VariantInInputPosition { command: String, position: String },
136    /// A command-tree node is not reachable from the root.
137    UnreachableCommandNode { index: i32 },
138    /// The command tree contains a cycle.
139    CommandTreeCycle { index: i32 },
140    /// A command-tree node is referenced as a subcommand by more than one
141    /// parent (the command graph is a DAG, not a tree).
142    DuplicateCommandParent { index: i32 },
143}
144
145impl Display for ToolValidationError {
146    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
147        match self {
148            ToolValidationError::EmptyVersion => write!(f, "the tool version is empty"),
149            ToolValidationError::EmptyCommandTree => {
150                write!(f, "the command tree is empty")
151            }
152            ToolValidationError::CommandIndexOutOfBounds { index, len } => {
153                write!(
154                    f,
155                    "command index {index} is out of bounds (tree has {len} nodes)"
156                )
157            }
158            ToolValidationError::InvalidIdentifier { kind, value } => {
159                write!(f, "invalid {kind}: {value:?}")
160            }
161            ToolValidationError::DuplicateSubcommandName { parent, name } => {
162                write!(
163                    f,
164                    "duplicate subcommand name {name:?} under command {parent:?}"
165                )
166            }
167            ToolValidationError::DuplicateName { command, name } => {
168                write!(f, "duplicate name {name:?} in command {command:?}")
169            }
170            ToolValidationError::DuplicateShort { command, short } => {
171                write!(f, "duplicate short form {short:?} in command {command:?}")
172            }
173            ToolValidationError::UnresolvedConstraintRef { command, name } => {
174                write!(
175                    f,
176                    "constraint references unknown name {name:?} in command {command:?}"
177                )
178            }
179            ToolValidationError::ValueIsTypeMismatch { command, name } => {
180                write!(
181                    f,
182                    "value-is literal for {name:?} is not valid for its type in command {command:?}"
183                )
184            }
185            ToolValidationError::DefaultTypeMismatch { command, name } => {
186                write!(
187                    f,
188                    "default literal for {name:?} is not valid for its type in command {command:?}"
189                )
190            }
191            ToolValidationError::RepeatableMapTypeNotMap { command, name } => {
192                write!(
193                    f,
194                    "repeatable-map option {name:?} in command {command:?} does not collect into a map type"
195                )
196            }
197            ToolValidationError::UnresolvedTypeRef {
198                command,
199                position,
200                id,
201            } => {
202                write!(
203                    f,
204                    "type reference {id:?} at position {position:?} in command {command:?} does not resolve to a definition in the tool schema"
205                )
206            }
207            ToolValidationError::IllFormedSchema {
208                command,
209                position,
210                detail,
211            } => {
212                write!(
213                    f,
214                    "type at position {position:?} in command {command:?} is not well-formed: {detail}"
215                )
216            }
217            ToolValidationError::DuplicateTypeId { id } => {
218                write!(f, "duplicate schema definition id {id:?}")
219            }
220            ToolValidationError::UnresolvedDefaultFormatter { command, formatter } => {
221                write!(
222                    f,
223                    "default-formatter {formatter:?} is not declared in command {command:?}"
224                )
225            }
226            ToolValidationError::VerbatimWithoutSeparator {
227                command,
228                positional,
229            } => {
230                write!(
231                    f,
232                    "tail positional {positional:?} in command {command:?} is verbatim but has no separator"
233                )
234            }
235            ToolValidationError::VariantInInputPosition { command, position } => {
236                write!(
237                    f,
238                    "a variant type is reachable from input position {position:?} in command {command:?}"
239                )
240            }
241            ToolValidationError::UnreachableCommandNode { index } => {
242                write!(f, "command node {index} is not reachable from the root")
243            }
244            ToolValidationError::CommandTreeCycle { index } => {
245                write!(f, "the command tree contains a cycle at node {index}")
246            }
247            ToolValidationError::DuplicateCommandParent { index } => {
248                write!(f, "command node {index} has more than one parent")
249            }
250        }
251    }
252}
253
254impl std::error::Error for ToolValidationError {}
255
256/// Validate a [`Tool`] against the producer-side construction invariants.
257pub fn validate_tool(tool: &Tool) -> Result<(), Vec<ToolValidationError>> {
258    let mut ctx = Validator::new(tool);
259    if tool.version.trim().is_empty() {
260        ctx.errors.push(ToolValidationError::EmptyVersion);
261    }
262    ctx.run();
263    if ctx.errors.is_empty() {
264        Ok(())
265    } else {
266        Err(ctx.errors)
267    }
268}
269
270/// A producer-side construction-invariant violation in tool middleware metadata.
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub enum ToolMiddlewareValidationError {
273    EmptyVersion,
274    InvalidIdentifier { kind: &'static str, value: String },
275    DuplicateIdentity { value: String },
276    InvalidParameterSchema(Vec<SchemaError>),
277    ForbiddenParameterType(&'static str),
278    InvalidPresentedTool(Vec<ToolValidationError>),
279    InvalidExpectedTool(Vec<ToolValidationError>),
280}
281
282impl Display for ToolMiddlewareValidationError {
283    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::EmptyVersion => write!(f, "the tool middleware version is empty"),
286            Self::InvalidIdentifier { kind, value } => write!(f, "invalid {kind}: {value:?}"),
287            Self::DuplicateIdentity { value } => {
288                write!(f, "duplicate tool middleware name or alias: {value:?}")
289            }
290            Self::InvalidParameterSchema(errors) => {
291                write!(f, "invalid tool middleware parameter schema: {errors:?}")
292            }
293            Self::ForbiddenParameterType(kind) => {
294                write!(
295                    f,
296                    "tool middleware parameter schema contains forbidden type {kind}"
297                )
298            }
299            Self::InvalidPresentedTool(errors) => {
300                write!(f, "invalid presented tool descriptor: {errors:?}")
301            }
302            Self::InvalidExpectedTool(errors) => {
303                write!(f, "invalid expected tool descriptor: {errors:?}")
304            }
305        }
306    }
307}
308
309impl std::error::Error for ToolMiddlewareValidationError {}
310
311/// Validate middleware identity metadata and every embedded tool descriptor.
312pub fn validate_tool_middleware(
313    middleware: &ToolMiddleware,
314) -> Result<(), Vec<ToolMiddlewareValidationError>> {
315    let mut errors = Vec::new();
316    if middleware.version.trim().is_empty() {
317        errors.push(ToolMiddlewareValidationError::EmptyVersion);
318    }
319    let mut identities = HashSet::new();
320    for (kind, identity) in std::iter::once(("tool middleware name", &middleware.name)).chain(
321        middleware
322            .aliases
323            .iter()
324            .map(|alias| ("tool middleware alias", alias)),
325    ) {
326        if !is_valid_identifier(identity) {
327            errors.push(ToolMiddlewareValidationError::InvalidIdentifier {
328                kind,
329                value: identity.clone(),
330            });
331        }
332        if !identities.insert(identity) {
333            errors.push(ToolMiddlewareValidationError::DuplicateIdentity {
334                value: identity.clone(),
335            });
336        }
337    }
338
339    if let Err(schema_errors) = validate_graph(&middleware.parameter_schema) {
340        errors.push(ToolMiddlewareValidationError::InvalidParameterSchema(
341            schema_errors,
342        ));
343    }
344    for ty in std::iter::once(&middleware.parameter_schema.root)
345        .chain(middleware.parameter_schema.defs.iter().map(|def| &def.body))
346    {
347        collect_forbidden_parameter_types(ty, &mut errors);
348    }
349
350    if let ToolMiddlewareScope::Monomorphic(scope) = &middleware.scope {
351        if let Err(tool_errors) = validate_tool(&scope.presented) {
352            errors.push(ToolMiddlewareValidationError::InvalidPresentedTool(
353                tool_errors,
354            ));
355        }
356        if let Some(expected) = &scope.expected
357            && let Err(tool_errors) = validate_tool(expected)
358        {
359            errors.push(ToolMiddlewareValidationError::InvalidExpectedTool(
360                tool_errors,
361            ));
362        }
363    }
364
365    if errors.is_empty() {
366        Ok(())
367    } else {
368        Err(errors)
369    }
370}
371
372fn collect_forbidden_parameter_types(
373    ty: &SchemaType,
374    errors: &mut Vec<ToolMiddlewareValidationError>,
375) {
376    match ty {
377        SchemaType::Secret { .. } => errors.push(
378            ToolMiddlewareValidationError::ForbiddenParameterType("secret"),
379        ),
380        SchemaType::QuotaToken { .. } => errors.push(
381            ToolMiddlewareValidationError::ForbiddenParameterType("quota-token"),
382        ),
383        SchemaType::PermissionCard { .. } => errors.push(
384            ToolMiddlewareValidationError::ForbiddenParameterType("permission-card"),
385        ),
386        SchemaType::Future { .. } => errors.push(
387            ToolMiddlewareValidationError::ForbiddenParameterType("future"),
388        ),
389        SchemaType::Stream { .. } => errors.push(
390            ToolMiddlewareValidationError::ForbiddenParameterType("stream"),
391        ),
392        SchemaType::Record { fields, .. } => fields
393            .iter()
394            .for_each(|field| collect_forbidden_parameter_types(&field.body, errors)),
395        SchemaType::Variant { cases, .. } => cases.iter().for_each(|case| {
396            if let Some(payload) = &case.payload {
397                collect_forbidden_parameter_types(payload, errors);
398            }
399        }),
400        SchemaType::Tuple { elements, .. } => elements
401            .iter()
402            .for_each(|element| collect_forbidden_parameter_types(element, errors)),
403        SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
404            collect_forbidden_parameter_types(element, errors)
405        }
406        SchemaType::Map { key, value, .. } => {
407            collect_forbidden_parameter_types(key, errors);
408            collect_forbidden_parameter_types(value, errors);
409        }
410        SchemaType::Option { inner, .. } => collect_forbidden_parameter_types(inner, errors),
411        SchemaType::Result { spec, .. } => {
412            if let Some(ok) = &spec.ok {
413                collect_forbidden_parameter_types(ok, errors);
414            }
415            if let Some(err) = &spec.err {
416                collect_forbidden_parameter_types(err, errors);
417            }
418        }
419        SchemaType::Union { spec, .. } => spec
420            .branches
421            .iter()
422            .for_each(|branch| collect_forbidden_parameter_types(&branch.body, errors)),
423        SchemaType::Ref { .. }
424        | SchemaType::Bool { .. }
425        | SchemaType::S8 { .. }
426        | SchemaType::S16 { .. }
427        | SchemaType::S32 { .. }
428        | SchemaType::S64 { .. }
429        | SchemaType::U8 { .. }
430        | SchemaType::U16 { .. }
431        | SchemaType::U32 { .. }
432        | SchemaType::U64 { .. }
433        | SchemaType::F32 { .. }
434        | SchemaType::F64 { .. }
435        | SchemaType::Char { .. }
436        | SchemaType::String { .. }
437        | SchemaType::Enum { .. }
438        | SchemaType::Flags { .. }
439        | SchemaType::Text { .. }
440        | SchemaType::Binary { .. }
441        | SchemaType::Path { .. }
442        | SchemaType::Url { .. }
443        | SchemaType::Uuid { .. }
444        | SchemaType::Datetime { .. }
445        | SchemaType::Duration { .. }
446        | SchemaType::Quantity { .. } => {}
447    }
448}
449
450/// An input position whose declared type must not reach a `variant`.
451struct InputRoot<'a> {
452    command: String,
453    position: String,
454    ty: &'a SchemaType,
455}
456
457struct Validator<'a> {
458    tool: &'a Tool,
459    errors: Vec<ToolValidationError>,
460    /// Types reachable from an input position; checked for `variant` after the
461    /// command traversal.
462    input_roots: Vec<InputRoot<'a>>,
463    /// Every [`SchemaType::Ref`] id written structurally in a command position
464    /// type (descending through inline constructors, but not following refs into
465    /// definition bodies). These are exactly the refs whose alias chains the
466    /// use-site well-formedness check ([`Self::check_type_well_formed`]) walks
467    /// and reports on, so they seed the suppression of redundant def-body copies
468    /// in [`Self::check_def_refs`].
469    used_ref_ids: HashSet<TypeId>,
470}
471
472/// How a `value-is` literal is matched against its comparand type. Mirrors the
473/// SDK runtime's `ValueIsMode` so host and guest agree on what a `value-is`
474/// against a collecting vs. non-collecting surface means.
475#[derive(Clone, Copy, PartialEq, Eq)]
476enum ValueIsMode {
477    /// A non-collecting value surface (a scalar option, a fixed positional): the
478    /// literal matches the whole declared value, or — under the one-level
479    /// relaxation — a single element of a list/fixed-list or value of a map
480    /// (optionally `option`-wrapped).
481    WholeOrOnePeel,
482    /// A collecting surface (a repeatable option or tail positional, whose
483    /// comparand is already the per-occurrence type): the literal matches one
484    /// occurrence exactly, with no element/value relaxation (matching one more
485    /// level would descend past a single occurrence).
486    Exact,
487}
488
489/// The `value-is` comparand recorded for a name that carries a declared value
490/// type. A name absent from the comparand map carries no value type at all (a
491/// flag), which is a genuine `value-is` mismatch.
492#[derive(Clone, Copy)]
493enum ValueComparand<'a> {
494    /// The resolved type a `value-is` literal must be valid for, plus the mode
495    /// controlling whether the one-level relaxation applies.
496    Type(&'a SchemaType, ValueIsMode),
497    /// The name is typed, but its declared type could not be resolved to a
498    /// comparable value type (a repeatable-map whose `map_type` does not resolve
499    /// to a map). The underlying type error is reported separately, so
500    /// `value-is` checking is suppressed to avoid a misleading cascade.
501    BlockedByTypeError,
502}
503
504/// The classification of a `value-is` literal against a declared value type.
505enum ValueIsOutcome {
506    /// The literal is a valid value for the declared type (or, under the
507    /// element relaxation, for its element type).
508    Compatible,
509    /// The literal is not valid for the declared type and the mismatch is
510    /// genuine (not merely an artifact of an unresolved reference).
511    Mismatch,
512    /// The comparison failed only because the literal descended into an
513    /// unresolved reference, reported separately as `UnresolvedTypeRef`; the
514    /// mismatch is suppressed to avoid misleading cascade noise.
515    BlockedByDanglingRef,
516}
517
518/// The set of names and declared types reachable from a command body, used for
519/// uniqueness checks and constraint-ref resolution.
520#[derive(Default)]
521struct NameScope<'a> {
522    /// All referenceable names (option/flag long names, aliases, positional
523    /// names).
524    names: HashSet<String>,
525    /// Names that carry a declared value type, mapped to the comparand a
526    /// `value-is` literal must be valid for (already unwrapped to the element
527    /// type for repeatable options and tail positionals). A name present in
528    /// [`Self::names`] but absent here is a flag (no value type).
529    typed: std::collections::HashMap<String, ValueComparand<'a>>,
530}
531
532impl<'a> Validator<'a> {
533    fn new(tool: &'a Tool) -> Self {
534        Self {
535            tool,
536            errors: Vec::new(),
537            input_roots: Vec::new(),
538            used_ref_ids: HashSet::new(),
539        }
540    }
541
542    fn run(&mut self) {
543        self.check_duplicate_type_ids();
544
545        if self.tool.commands.nodes.is_empty() {
546            self.errors.push(ToolValidationError::EmptyCommandTree);
547        }
548
549        if !self.tool.commands.nodes.is_empty() {
550            let mut visited = HashSet::new();
551            let mut on_stack = HashSet::new();
552            self.visit_command(CommandIndex(0), &[], &mut visited, &mut on_stack);
553            for i in 0..self.tool.commands.nodes.len() {
554                if !visited.contains(&(i as i32)) {
555                    self.errors
556                        .push(ToolValidationError::UnreachableCommandNode { index: i as i32 });
557                }
558            }
559        }
560
561        self.check_no_variant_in_input();
562        self.check_def_refs();
563    }
564
565    fn command_node(&self, index: CommandIndex) -> Option<&'a CommandNode> {
566        index
567            .as_usize()
568            .and_then(|i| self.tool.commands.nodes.get(i))
569    }
570
571    fn check_identifier(&mut self, kind: &'static str, value: &str) {
572        if !is_valid_identifier(value) {
573            self.errors.push(ToolValidationError::InvalidIdentifier {
574                kind,
575                value: value.to_string(),
576            });
577        }
578    }
579
580    // ---- command tree ----
581
582    fn visit_command(
583        &mut self,
584        index: CommandIndex,
585        ancestor_globals: &[&'a Globals],
586        visited: &mut HashSet<i32>,
587        on_stack: &mut HashSet<i32>,
588    ) {
589        if on_stack.contains(&index.0) {
590            self.errors
591                .push(ToolValidationError::CommandTreeCycle { index: index.0 });
592            return;
593        }
594        if !visited.insert(index.0) {
595            // Reached again through a different parent path: the command graph is
596            // a DAG, not a tree.
597            self.errors
598                .push(ToolValidationError::DuplicateCommandParent { index: index.0 });
599            return;
600        }
601        let Some(node) = self.command_node(index) else {
602            return;
603        };
604        on_stack.insert(index.0);
605
606        self.check_identifier("command name", &node.name);
607        for alias in &node.aliases {
608            self.check_identifier("command alias", alias);
609        }
610        self.check_globals(&node.name, &node.globals);
611        self.check_global_scope_uniqueness(&node.name, ancestor_globals, &node.globals);
612
613        // In-scope globals for this command's own body: ancestors plus this
614        // command's own globals.
615        let mut in_scope: Vec<&'a Globals> = ancestor_globals.to_vec();
616        in_scope.push(&node.globals);
617
618        if let Some(body) = &node.body {
619            self.check_body(&node.name, body, &in_scope);
620        }
621
622        self.check_subcommand_uniqueness(node);
623
624        for sub in &node.subcommands {
625            let len = self.tool.commands.nodes.len();
626            if sub.as_usize().filter(|i| *i < len).is_none() {
627                self.errors
628                    .push(ToolValidationError::CommandIndexOutOfBounds { index: sub.0, len });
629                continue;
630            }
631            self.visit_command(*sub, &in_scope, visited, on_stack);
632        }
633
634        on_stack.remove(&index.0);
635    }
636
637    fn check_globals(&mut self, command: &str, globals: &'a Globals) {
638        for opt in &globals.options {
639            self.check_option_identifiers(opt);
640            self.check_repeatable_map(command, opt);
641            self.check_option_default(command, opt);
642            self.check_type_well_formed(command, &opt.long, option_input_type(opt));
643            // A global option is an input position; its full collected input
644            // type (both map key and value for a repeatable-map) must not reach
645            // a variant.
646            self.input_roots.push(InputRoot {
647                command: command.to_string(),
648                position: opt.long.clone(),
649                ty: option_input_type(opt),
650            });
651        }
652        for flag in &globals.flags {
653            self.check_identifier("flag long name", &flag.long);
654            for alias in &flag.aliases {
655                self.check_identifier("flag alias", alias);
656            }
657        }
658    }
659
660    /// Report collisions among the globals visible at a command: the command's
661    /// own globals must be unique among themselves and against every ancestor
662    /// global. Reported once, at the command that introduces the colliding
663    /// token, so a collision is not re-reported at every descendant body.
664    fn check_global_scope_uniqueness(
665        &mut self,
666        command: &str,
667        ancestors: &[&'a Globals],
668        own: &Globals,
669    ) {
670        let mut names: HashSet<String> = HashSet::new();
671        let mut shorts: HashSet<char> = HashSet::new();
672        for globals in ancestors {
673            for opt in &globals.options {
674                names.insert(opt.long.clone());
675                names.extend(opt.aliases.iter().cloned());
676                if let Some(short) = opt.short {
677                    shorts.insert(short);
678                }
679            }
680            for flag in &globals.flags {
681                names.insert(flag.long.clone());
682                names.extend(flag.aliases.iter().cloned());
683                if let Some(short) = flag.short {
684                    shorts.insert(short);
685                }
686            }
687        }
688
689        let mut add_name = |this: &mut Self, name: &str| {
690            if !names.insert(name.to_string()) {
691                this.errors.push(ToolValidationError::DuplicateName {
692                    command: command.to_string(),
693                    name: name.to_string(),
694                });
695            }
696        };
697        for opt in &own.options {
698            add_name(self, &opt.long);
699            for alias in &opt.aliases {
700                add_name(self, alias);
701            }
702            if let Some(short) = opt.short
703                && !shorts.insert(short)
704            {
705                self.errors.push(ToolValidationError::DuplicateShort {
706                    command: command.to_string(),
707                    short,
708                });
709            }
710        }
711        for flag in &own.flags {
712            add_name(self, &flag.long);
713            for alias in &flag.aliases {
714                add_name(self, alias);
715            }
716            if let Some(short) = flag.short
717                && !shorts.insert(short)
718            {
719                self.errors.push(ToolValidationError::DuplicateShort {
720                    command: command.to_string(),
721                    short,
722                });
723            }
724        }
725    }
726
727    fn check_option_identifiers(&mut self, opt: &OptionSpec) {
728        self.check_identifier("option long name", &opt.long);
729        for alias in &opt.aliases {
730            self.check_identifier("option alias", alias);
731        }
732    }
733
734    /// A `repeatable-map` option collects its occurrences into a single map
735    /// value, so its stored `map_type` must resolve to a [`SchemaType::Map`].
736    fn check_repeatable_map(&mut self, command: &str, opt: &OptionSpec) {
737        if let OptionShape::RepeatableMap(shape) = &opt.shape {
738            // Whether the stored type is a map is decided by its *top-level*
739            // resolution only. A `map_type` that resolves to a concrete non-map
740            // type — or to a reference cycle, which can never resolve to a map —
741            // is a genuine "not a map" error, even if some nested field is itself
742            // a dangling reference. A `map_type` that is itself a *dangling*
743            // reference cannot be classified and is reported separately as
744            // `UnresolvedTypeRef`, so reporting a "not a map" error on top of it
745            // would be misleading.
746            let not_a_map = match self.tool.schema.resolve_ref(&shape.map_type) {
747                Ok(SchemaType::Map { .. }) => false,
748                Ok(_) | Err(RefResolutionError::RecursiveRef(_)) => true,
749                Err(RefResolutionError::DanglingRef(_)) => false,
750            };
751            if not_a_map {
752                self.errors
753                    .push(ToolValidationError::RepeatableMapTypeNotMap {
754                        command: command.to_string(),
755                        name: opt.long.clone(),
756                    });
757            }
758        }
759    }
760
761    /// Validate an option's `default` literal (if present) against its declared
762    /// value type. A repeatable option's default is the whole collected value:
763    /// a `repeatable-list` default is validated against `list<element>`, a
764    /// `repeatable-map` default against the map node directly; scalar and
765    /// optional-scalar defaults are validated against the scalar type directly.
766    fn check_option_default(&mut self, command: &str, opt: &OptionSpec) {
767        let Some(default) = &opt.default else {
768            return;
769        };
770        let graph = &self.tool.schema;
771        // A mismatch is suppressed only when it is *purely* an artifact of an
772        // unresolved reference the value descended into (reported separately as
773        // `UnresolvedTypeRef`); an independent shape mismatch is still reported.
774        let result = match &opt.shape {
775            OptionShape::Scalar(ty) | OptionShape::OptionalScalar(ty) => {
776                validate_value(graph, ty, default)
777            }
778            OptionShape::RepeatableList(shape) => {
779                let list_ty = SchemaType::list(shape.item_type.clone());
780                validate_value(graph, &list_ty, default)
781            }
782            OptionShape::RepeatableMap(shape) => validate_value(graph, &shape.map_type, default),
783        };
784        if let Err(errors) = result
785            && !value_mismatch_is_only_dangling(&errors)
786        {
787            self.errors.push(ToolValidationError::DefaultTypeMismatch {
788                command: command.to_string(),
789                name: opt.long.clone(),
790            });
791        }
792    }
793
794    /// Validate a fixed positional's `default` literal (if present) against its
795    /// declared type.
796    fn check_positional_default(&mut self, command: &str, positional: &Positional) {
797        if let Some(default) = &positional.default
798            && let Err(errors) = validate_value(&self.tool.schema, &positional.type_, default)
799            // Suppress only when the mismatch is purely an artifact of an
800            // unresolved reference (reported separately as `UnresolvedTypeRef`).
801            && !value_mismatch_is_only_dangling(&errors)
802        {
803            self.errors.push(ToolValidationError::DefaultTypeMismatch {
804                command: command.to_string(),
805                name: positional.name.clone(),
806            });
807        }
808    }
809
810    /// Validate an embedded type at an input/output position for structural
811    /// well-formedness against the tool's schema graph: every
812    /// [`SchemaType::Ref`] must resolve to a definition, and every inline
813    /// restriction (numeric bounds, text/binary ranges, union discriminators,
814    /// ...) must be valid. References inside a resolved definition's body are
815    /// not followed here; definition bodies are validated once by
816    /// [`Self::check_def_refs`].
817    ///
818    /// A dangling reference is reported as
819    /// [`ToolValidationError::UnresolvedTypeRef`]; any other well-formedness
820    /// failure is reported as [`ToolValidationError::IllFormedSchema`]. When a
821    /// type has dangling references, only those are reported (structural errors
822    /// caused by the missing definition would be misleading noise).
823    fn check_type_well_formed(&mut self, command: &str, position: &str, ty: &SchemaType) {
824        collect_structural_ref_ids(ty, &mut self.used_ref_ids);
825        if let Err(errors) = validate_root_type(&self.tool.schema, ty) {
826            self.errors.extend(map_schema_errors(
827                command.to_string(),
828                position.to_string(),
829                errors,
830            ));
831        }
832    }
833
834    /// Report definitions that share a type id. The wire [`GraphEncoder`] and
835    /// schema well-formedness both require ids to be unique, so a duplicate is a
836    /// producer-side invariant violation even though each individual body may be
837    /// well-formed.
838    fn check_duplicate_type_ids(&mut self) {
839        let mut seen: HashSet<String> = HashSet::new();
840        for def in &self.tool.schema.defs {
841            if !seen.insert(def.id.to_string()) {
842                self.errors.push(ToolValidationError::DuplicateTypeId {
843                    id: def.id.to_string(),
844                });
845            }
846        }
847    }
848
849    /// Check that every named definition body is well-formed and references only
850    /// definitions that exist in the graph. `GraphEncoder` (in the WIT conversion layer) encodes
851    /// all definition bodies eagerly, so an unresolved reference in any
852    /// definition — even an unreferenced one — would fail wire encoding.
853    fn check_def_refs(&mut self) {
854        // A pure-alias definition (a bare `Ref` body) reports only an alias-chain
855        // failure (dangling or recursive), and that exact failure is already
856        // reported by every position whose `resolve_ref` walks into it: a command
857        // position, or a constructor definition body (which is always validated
858        // and whose nested refs are walked). Validating such a pure alias again
859        // would double-report. So seed the suppression set from command-position
860        // refs and from the refs structurally written in constructor definition
861        // bodies, then follow pure-alias edges. Pure aliases nothing references
862        // (including an unreferenced alias cycle) are *not* seeded, so they are
863        // still validated and reported at their own `<def ...>` position.
864        let mut seeds = self.used_ref_ids.clone();
865        for def in &self.tool.schema.defs {
866            if !matches!(def.body, SchemaType::Ref { .. }) {
867                collect_structural_ref_ids(&def.body, &mut seeds);
868            }
869        }
870        let covered = pure_alias_closure(&self.tool.schema, &seeds);
871        // Ids carried by more than one definition. Pure-alias coverage is keyed
872        // by `TypeId`, but a duplicate-id graph is already malformed (reported as
873        // `DuplicateTypeId`) and id resolution is ambiguous, so suppression
874        // cannot safely identify which same-id body is the covered alias.
875        // Validate every duplicate-id body so none of their failures are lost.
876        let mut seen_ids: HashSet<&TypeId> = HashSet::new();
877        let mut duplicate_ids: HashSet<&TypeId> = HashSet::new();
878        for def in &self.tool.schema.defs {
879            if !seen_ids.insert(&def.id) {
880                duplicate_ids.insert(&def.id);
881            }
882        }
883        let mut reported: Vec<ToolValidationError> = Vec::new();
884        for def in &self.tool.schema.defs {
885            // Skip only a uniquely-named pure-alias definition that the closure
886            // marked as covered: its single possible failure is already reported
887            // at the position whose chain walk reaches it.
888            if covered.contains(&def.id)
889                && matches!(def.body, SchemaType::Ref { .. })
890                && !duplicate_ids.contains(&def.id)
891            {
892                continue;
893            }
894            if let Err(errors) = validate_root_type(&self.tool.schema, &def.body) {
895                let def_id = def.id.to_string();
896                reported.extend(map_schema_errors(format!("<def {def_id}>"), def_id, errors));
897            }
898        }
899        self.errors.extend(reported);
900    }
901
902    fn check_subcommand_uniqueness(&mut self, node: &'a CommandNode) {
903        let mut seen: HashSet<&str> = HashSet::new();
904        for sub in &node.subcommands {
905            let Some(child) = self.command_node(*sub) else {
906                continue;
907            };
908            for token in std::iter::once(&child.name).chain(child.aliases.iter()) {
909                if !seen.insert(token.as_str()) {
910                    self.errors
911                        .push(ToolValidationError::DuplicateSubcommandName {
912                            parent: node.name.clone(),
913                            name: token.clone(),
914                        });
915                }
916            }
917        }
918    }
919
920    fn check_body(&mut self, command: &str, body: &'a CommandBody, in_scope: &[&'a Globals]) {
921        // The tool's shared schema graph, used to resolve `value-is` comparands
922        // through any `repeatable-map` `Ref` map types. Bound to the tool's `'a`
923        // lifetime (independent of the `&mut self` borrow) so resolved types can
924        // live in the name scope.
925        let graph: &'a SchemaGraph = &self.tool.schema;
926        // Build the in-scope global token sets first so body-local tokens can be
927        // checked against them.
928        let mut global_names: HashSet<String> = HashSet::new();
929        let mut global_shorts: HashSet<char> = HashSet::new();
930        for globals in in_scope {
931            for opt in &globals.options {
932                global_names.insert(opt.long.clone());
933                global_names.extend(opt.aliases.iter().cloned());
934                if let Some(short) = opt.short {
935                    global_shorts.insert(short);
936                }
937            }
938            for flag in &globals.flags {
939                global_names.insert(flag.long.clone());
940                global_names.extend(flag.aliases.iter().cloned());
941                if let Some(short) = flag.short {
942                    global_shorts.insert(short);
943                }
944            }
945        }
946
947        // Body-local uniqueness, accumulated incrementally so collisions both
948        // within the body and against globals are reported.
949        let mut names = global_names.clone();
950        let mut shorts = global_shorts.clone();
951        let mut add_name = |this: &mut Self, name: &str| {
952            if !names.insert(name.to_string()) {
953                this.errors.push(ToolValidationError::DuplicateName {
954                    command: command.to_string(),
955                    name: name.to_string(),
956                });
957            }
958        };
959
960        // The resolution scope (for constraint refs / value-is) includes both
961        // globals and body-declared names.
962        let mut scope = NameScope::default();
963        for globals in in_scope {
964            for opt in &globals.options {
965                register_option(graph, &mut scope, opt);
966            }
967            for flag in &globals.flags {
968                scope.names.insert(flag.long.clone());
969                scope.names.extend(flag.aliases.iter().cloned());
970            }
971        }
972
973        for opt in &body.options {
974            self.check_option_identifiers(opt);
975            add_name(self, &opt.long);
976            for alias in &opt.aliases {
977                add_name(self, alias);
978            }
979            if let Some(short) = opt.short
980                && !shorts.insert(short)
981            {
982                self.errors.push(ToolValidationError::DuplicateShort {
983                    command: command.to_string(),
984                    short,
985                });
986            }
987            register_option(graph, &mut scope, opt);
988            self.check_repeatable_map(command, opt);
989            self.check_option_default(command, opt);
990            self.check_type_well_formed(command, &opt.long, option_input_type(opt));
991            // A body option is an input position; its value type must not reach
992            // a variant.
993            self.input_roots.push(InputRoot {
994                command: command.to_string(),
995                position: opt.long.clone(),
996                ty: option_input_type(opt),
997            });
998        }
999
1000        for flag in &body.flags {
1001            self.check_identifier("flag long name", &flag.long);
1002            for alias in &flag.aliases {
1003                self.check_identifier("flag alias", alias);
1004            }
1005            add_name(self, &flag.long);
1006            for alias in &flag.aliases {
1007                add_name(self, alias);
1008            }
1009            if let Some(short) = flag.short
1010                && !shorts.insert(short)
1011            {
1012                self.errors.push(ToolValidationError::DuplicateShort {
1013                    command: command.to_string(),
1014                    short,
1015                });
1016            }
1017            scope.names.insert(flag.long.clone());
1018            scope.names.extend(flag.aliases.iter().cloned());
1019        }
1020
1021        for positional in &body.positionals.fixed {
1022            self.check_identifier("positional name", &positional.name);
1023            add_name(self, &positional.name);
1024            scope.names.insert(positional.name.clone());
1025            scope.typed.insert(
1026                positional.name.clone(),
1027                ValueComparand::Type(&positional.type_, ValueIsMode::WholeOrOnePeel),
1028            );
1029            self.check_positional_default(command, positional);
1030            self.check_type_well_formed(command, &positional.name, &positional.type_);
1031            self.input_roots.push(InputRoot {
1032                command: command.to_string(),
1033                position: positional.name.clone(),
1034                ty: &positional.type_,
1035            });
1036        }
1037
1038        if let Some(tail) = &body.positionals.tail {
1039            self.check_identifier("positional name", &tail.name);
1040            add_name(self, &tail.name);
1041            scope.names.insert(tail.name.clone());
1042            // A tail positional collects occurrences into a list; a value-is
1043            // literal matches one item exactly (the per-occurrence item type),
1044            // never the whole collected list.
1045            scope.typed.insert(
1046                tail.name.clone(),
1047                ValueComparand::Type(&tail.item_type, ValueIsMode::Exact),
1048            );
1049            self.check_type_well_formed(command, &tail.name, &tail.item_type);
1050            self.input_roots.push(InputRoot {
1051                command: command.to_string(),
1052                position: tail.name.clone(),
1053                ty: &tail.item_type,
1054            });
1055            if tail.verbatim && tail.separator.is_none() {
1056                self.errors
1057                    .push(ToolValidationError::VerbatimWithoutSeparator {
1058                        command: command.to_string(),
1059                        positional: tail.name.clone(),
1060                    });
1061            }
1062        }
1063
1064        for constraint in &body.constraints {
1065            self.check_constraint(command, constraint, &scope);
1066        }
1067
1068        if let Some(result) = &body.result {
1069            for formatter in &result.formatters {
1070                self.check_identifier("formatter name", &formatter.name);
1071            }
1072            let resolved = result
1073                .formatters
1074                .iter()
1075                .any(|f| f.name == result.default_formatter);
1076            if !resolved {
1077                self.errors
1078                    .push(ToolValidationError::UnresolvedDefaultFormatter {
1079                        command: command.to_string(),
1080                        formatter: result.default_formatter.clone(),
1081                    });
1082            }
1083            self.check_type_well_formed(command, "result", &result.type_);
1084        }
1085
1086        for error_case in &body.errors {
1087            self.check_identifier("error-case name", &error_case.name);
1088            if let Some(payload) = &error_case.payload {
1089                self.check_type_well_formed(command, &error_case.name, payload);
1090            }
1091        }
1092    }
1093
1094    fn check_constraint(&mut self, command: &str, constraint: &Constraint, scope: &NameScope) {
1095        let refs = collect_refs(constraint);
1096        for r in refs {
1097            match r {
1098                Ref::Present(name) => {
1099                    if !scope.names.contains(name) {
1100                        self.errors
1101                            .push(ToolValidationError::UnresolvedConstraintRef {
1102                                command: command.to_string(),
1103                                name: name.clone(),
1104                            });
1105                    }
1106                }
1107                Ref::ValueIs(value_is) => {
1108                    if !scope.names.contains(&value_is.name) {
1109                        self.errors
1110                            .push(ToolValidationError::UnresolvedConstraintRef {
1111                                command: command.to_string(),
1112                                name: value_is.name.clone(),
1113                            });
1114                        continue;
1115                    }
1116                    match scope.typed.get(&value_is.name) {
1117                        Some(ValueComparand::Type(declared, mode)) => {
1118                            match self.value_is_outcome(declared, *mode, &value_is.value) {
1119                                ValueIsOutcome::Compatible => {}
1120                                ValueIsOutcome::Mismatch => {
1121                                    self.errors.push(ToolValidationError::ValueIsTypeMismatch {
1122                                        command: command.to_string(),
1123                                        name: value_is.name.clone(),
1124                                    });
1125                                }
1126                                ValueIsOutcome::BlockedByDanglingRef => {
1127                                    // The literal descended into an unresolved
1128                                    // reference, reported separately as
1129                                    // `UnresolvedTypeRef`; a value-is mismatch
1130                                    // here would be misleading cascade noise.
1131                                }
1132                            }
1133                        }
1134                        Some(ValueComparand::BlockedByTypeError) => {
1135                            // The name is typed but its declared type could not
1136                            // be resolved to a comparable value type (a
1137                            // repeatable-map whose `map_type` is not a map); the
1138                            // underlying type error is reported separately, so a
1139                            // value-is mismatch here would be misleading noise.
1140                        }
1141                        None => {
1142                            // Name resolves to a flag (no value type), so a
1143                            // value-is literal cannot be type-compatible.
1144                            self.errors.push(ToolValidationError::ValueIsTypeMismatch {
1145                                command: command.to_string(),
1146                                name: value_is.name.clone(),
1147                            });
1148                        }
1149                    }
1150                }
1151            }
1152        }
1153    }
1154
1155    /// Classify a `value-is` literal against the declared type. The literal is
1156    /// [`ValueIsOutcome::Compatible`] if it is a valid value for the declared
1157    /// type. For a [`ValueIsMode::WholeOrOnePeel`] comparand (a non-collecting
1158    /// value surface) it is *also* compatible — under the "any element / entry
1159    /// equals this literal" relaxation — for the element type of a list/fixed-list
1160    /// or the value type of a map (optionally `option`-wrapped) declared type. A
1161    /// [`ValueIsMode::Exact`] comparand (a collecting surface, whose type is
1162    /// already the per-occurrence type) gets no relaxation: matching one more
1163    /// level would descend past a single occurrence.
1164    ///
1165    /// When neither comparison holds, the result is [`ValueIsOutcome::Mismatch`]
1166    /// unless the relevant comparison failed *purely* because the value
1167    /// descended into an unresolved reference, in which case it is
1168    /// [`ValueIsOutcome::BlockedByDanglingRef`] and the mismatch is suppressed
1169    /// (the unresolved reference is reported separately as `UnresolvedTypeRef`).
1170    fn value_is_outcome(
1171        &self,
1172        declared: &SchemaType,
1173        mode: ValueIsMode,
1174        value: &SchemaValue,
1175    ) -> ValueIsOutcome {
1176        let graph = &self.tool.schema;
1177        let direct = validate_value(graph, declared, value);
1178        if direct.is_ok() {
1179            return ValueIsOutcome::Compatible;
1180        }
1181
1182        // A collecting surface's comparand is already the per-occurrence type; no
1183        // element/value relaxation applies. Classify on the direct comparison.
1184        if mode == ValueIsMode::Exact {
1185            return match direct {
1186                Err(errors) if value_mismatch_is_only_dangling(&errors) => {
1187                    ValueIsOutcome::BlockedByDanglingRef
1188                }
1189                _ => ValueIsOutcome::Mismatch,
1190            };
1191        }
1192
1193        // "any element/entry" relaxation: peel `option` wrappers (resolving refs
1194        // along the way) and, for a list/fixed-list-shaped declared type, compare
1195        // the literal against the element type, or for a map-shaped type against
1196        // the value type. When this relaxation applies, its comparison is the
1197        // relevant one for classification.
1198        if let Ok(mut peeled) = graph.resolve_ref(declared) {
1199            while let SchemaType::Option { inner, .. } = peeled {
1200                match graph.resolve_ref(inner) {
1201                    Ok(next) => peeled = next,
1202                    // The wrapped type is a *dangling* reference, so neither the
1203                    // option-inner nor the element relaxation can be decided; the
1204                    // missing reference is reported separately as
1205                    // `UnresolvedTypeRef`, so the mismatch is suppressed.
1206                    Err(RefResolutionError::DanglingRef(_)) => {
1207                        return ValueIsOutcome::BlockedByDanglingRef;
1208                    }
1209                    // A reference cycle never bottoms out in a concrete type, so
1210                    // no literal can ever be valid for it: a genuine mismatch.
1211                    Err(RefResolutionError::RecursiveRef(_)) => {
1212                        return ValueIsOutcome::Mismatch;
1213                    }
1214                }
1215            }
1216            let relaxed = match peeled {
1217                SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
1218                    Some(element.as_ref())
1219                }
1220                SchemaType::Map { value: v, .. } => Some(v.as_ref()),
1221                _ => None,
1222            };
1223            if let Some(inner_ty) = relaxed {
1224                return match validate_value(graph, inner_ty, value) {
1225                    Ok(()) => ValueIsOutcome::Compatible,
1226                    Err(errors) if value_mismatch_is_only_dangling(&errors) => {
1227                        ValueIsOutcome::BlockedByDanglingRef
1228                    }
1229                    Err(_) => ValueIsOutcome::Mismatch,
1230                };
1231            }
1232        }
1233
1234        // No relaxation applied: classify on the direct comparison.
1235        match direct {
1236            Err(errors) if value_mismatch_is_only_dangling(&errors) => {
1237                ValueIsOutcome::BlockedByDanglingRef
1238            }
1239            _ => ValueIsOutcome::Mismatch,
1240        }
1241    }
1242
1243    fn check_no_variant_in_input(&mut self) {
1244        let graph = &self.tool.schema;
1245        let mut errors = Vec::new();
1246        for root in &self.input_roots {
1247            let mut visited: HashSet<String> = HashSet::new();
1248            if type_reaches_variant(graph, root.ty, &mut visited) {
1249                errors.push(ToolValidationError::VariantInInputPosition {
1250                    command: root.command.clone(),
1251                    position: root.position.clone(),
1252                });
1253            }
1254        }
1255        self.errors.extend(errors);
1256    }
1257}
1258
1259/// The type a `value-is` literal for this option is compared against. For a
1260/// `repeatable-list` option this is the element type and for a `repeatable-map`
1261/// option the map's value type, matching the "any occurrence / entry equals this
1262/// literal" semantics.
1263///
1264/// A `repeatable-map`'s `map_type` may itself be a [`SchemaType::Ref`], so it is
1265/// resolved through `graph` before its value type is extracted. Returns `None`
1266/// when the map type is dangling or does not resolve to a [`SchemaType::Map`];
1267/// those shapes are reported separately (`UnresolvedTypeRef` /
1268/// `RepeatableMapTypeNotMap`), so the `value-is` comparand is simply skipped to
1269/// avoid a misleading cascading `ValueIsTypeMismatch`.
1270fn option_value_type<'a>(graph: &'a SchemaGraph, opt: &'a OptionSpec) -> Option<&'a SchemaType> {
1271    match &opt.shape {
1272        OptionShape::Scalar(t) | OptionShape::OptionalScalar(t) => Some(t),
1273        OptionShape::RepeatableList(shape) => Some(&shape.item_type),
1274        OptionShape::RepeatableMap(shape) => match graph.resolve_ref(&shape.map_type).ok()? {
1275            SchemaType::Map { value, .. } => Some(value),
1276            _ => None,
1277        },
1278    }
1279}
1280
1281/// The full collected input type of an option, used for type-reference
1282/// resolution and the "no variant in input position" check. A `repeatable-list`
1283/// stores its element type (the `list` wrapper carries no extra node); a
1284/// `repeatable-map` stores the whole `map` node so both key and value types are
1285/// reached.
1286fn option_input_type(opt: &OptionSpec) -> &SchemaType {
1287    match &opt.shape {
1288        OptionShape::Scalar(t) | OptionShape::OptionalScalar(t) => t,
1289        OptionShape::RepeatableList(shape) => &shape.item_type,
1290        OptionShape::RepeatableMap(shape) => &shape.map_type,
1291    }
1292}
1293
1294fn register_option<'a>(graph: &'a SchemaGraph, scope: &mut NameScope<'a>, opt: &'a OptionSpec) {
1295    scope.names.insert(opt.long.clone());
1296    scope.names.extend(opt.aliases.iter().cloned());
1297    // An option always carries a value type, so it is always registered as a
1298    // comparand. When that type does not resolve to a comparable value type (a
1299    // repeatable-map whose `map_type` is not a map) the comparand is
1300    // `BlockedByTypeError` so `value-is` checking is suppressed; the underlying
1301    // type error is reported elsewhere. A dangling reference inside an otherwise
1302    // comparable type is handled at check time by [`Validator::value_is_outcome`].
1303    let comparand = match option_value_type(graph, opt) {
1304        Some(ty) => ValueComparand::Type(ty, option_value_is_mode(opt)),
1305        None => ValueComparand::BlockedByTypeError,
1306    };
1307    scope.typed.insert(opt.long.clone(), comparand);
1308    for alias in &opt.aliases {
1309        scope.typed.insert(alias.clone(), comparand);
1310    }
1311}
1312
1313/// The `value-is` matching mode for an option: a scalar / optional-scalar option
1314/// is a non-collecting value surface (its declared value is matched with the
1315/// one-level relaxation); a repeatable-list or repeatable-map option collects
1316/// occurrences, so its per-occurrence comparand (element / map value type) is
1317/// matched exactly.
1318fn option_value_is_mode(opt: &OptionSpec) -> ValueIsMode {
1319    match &opt.shape {
1320        OptionShape::Scalar(_) | OptionShape::OptionalScalar(_) => ValueIsMode::WholeOrOnePeel,
1321        OptionShape::RepeatableList(_) | OptionShape::RepeatableMap(_) => ValueIsMode::Exact,
1322    }
1323}
1324
1325/// Returns `true` if `ty` (resolving named references against `graph`) reaches a
1326/// [`SchemaType::Variant`]. `visited` records the ref ids currently being
1327/// resolved so recursive graphs terminate.
1328fn type_reaches_variant(
1329    graph: &SchemaGraph,
1330    ty: &SchemaType,
1331    visited: &mut HashSet<String>,
1332) -> bool {
1333    if let SchemaType::Ref { id, .. } = ty
1334        && !visited.insert(id.to_string())
1335    {
1336        return false;
1337    }
1338    let Ok(resolved) = graph.resolve_ref(ty) else {
1339        return false;
1340    };
1341    match resolved {
1342        SchemaType::Variant { .. } => true,
1343        SchemaType::Record { fields, .. } => fields
1344            .iter()
1345            .any(|f| type_reaches_variant(graph, &f.body, visited)),
1346        SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
1347            type_reaches_variant(graph, element, visited)
1348        }
1349        SchemaType::Option { inner, .. } => type_reaches_variant(graph, inner, visited),
1350        SchemaType::Map { key, value, .. } => {
1351            type_reaches_variant(graph, key, visited) || type_reaches_variant(graph, value, visited)
1352        }
1353        SchemaType::Tuple { elements, .. } => elements
1354            .iter()
1355            .any(|e| type_reaches_variant(graph, e, visited)),
1356        SchemaType::Result { spec, .. } => {
1357            spec.ok
1358                .as_deref()
1359                .is_some_and(|t| type_reaches_variant(graph, t, visited))
1360                || spec
1361                    .err
1362                    .as_deref()
1363                    .is_some_and(|t| type_reaches_variant(graph, t, visited))
1364        }
1365        SchemaType::Union { spec, .. } => spec
1366            .branches
1367            .iter()
1368            .any(|b| type_reaches_variant(graph, &b.body, visited)),
1369        SchemaType::Future { inner: Some(t), .. } | SchemaType::Stream { inner: Some(t), .. } => {
1370            type_reaches_variant(graph, t, visited)
1371        }
1372        _ => false,
1373    }
1374}
1375
1376/// Returns `true` when a value/type comparison failed *only* because the value
1377/// descended into an unresolved (dangling) reference — every reported
1378/// [`ValueError`] is a [`ValueError::DanglingRef`]. In that case the dependent
1379/// mismatch (default / `value-is`) is an artifact of the missing reference,
1380/// which is reported separately as [`ToolValidationError::UnresolvedTypeRef`],
1381/// so it is suppressed. An empty error slice never counts as "only dangling".
1382fn value_mismatch_is_only_dangling(errors: &[ValueError]) -> bool {
1383    !errors.is_empty()
1384        && errors
1385            .iter()
1386            .all(|e| matches!(e, ValueError::DanglingRef { .. }))
1387}
1388
1389/// Map [`SchemaError`]s from well-formedness validation of a single embedded
1390/// type into position-aware [`ToolValidationError`]s. A dangling reference
1391/// becomes [`ToolValidationError::UnresolvedTypeRef`]; any other structural
1392/// failure becomes [`ToolValidationError::IllFormedSchema`].
1393///
1394/// Each error is mapped independently: a dangling reference never hides a
1395/// genuine, unrelated schema error elsewhere in the same type (for example an
1396/// invalid numeric restriction or a non-primitive map key on a sibling field).
1397/// Well-formedness already avoids emitting cascade errors that are merely an
1398/// artifact of a missing reference (for example it does not report
1399/// `MapKeyNotPrimitive` for a key that is itself a dangling reference), so no
1400/// suppression is needed here.
1401fn map_schema_errors(
1402    command: String,
1403    position: String,
1404    errors: Vec<SchemaError>,
1405) -> Vec<ToolValidationError> {
1406    // A single position can reference the same broken target id from several
1407    // spots (for example two record fields aliasing the same dangling type, or
1408    // two members of one record naming the same recursive alias). Those are a
1409    // single fact about that target id at this position, so collapse repeated
1410    // alias-chain failures (`DanglingRef` / `RecursiveAlias`) that share a
1411    // target id. Structural errors carry no shared target and are never merged:
1412    // two distinct ill-formed spots remain two facts.
1413    let mut seen_chain_targets: HashSet<TypeId> = HashSet::new();
1414    let mut mapped = Vec::with_capacity(errors.len());
1415    for error in errors {
1416        match error {
1417            SchemaError::DanglingRef(id) => {
1418                if seen_chain_targets.insert(id.clone()) {
1419                    mapped.push(ToolValidationError::UnresolvedTypeRef {
1420                        command: command.clone(),
1421                        position: position.clone(),
1422                        id: id.to_string(),
1423                    });
1424                }
1425            }
1426            SchemaError::RecursiveAlias(id) => {
1427                if seen_chain_targets.insert(id.clone()) {
1428                    mapped.push(ToolValidationError::IllFormedSchema {
1429                        command: command.clone(),
1430                        position: position.clone(),
1431                        detail: SchemaError::RecursiveAlias(id).to_string(),
1432                    });
1433                }
1434            }
1435            other => mapped.push(ToolValidationError::IllFormedSchema {
1436                command: command.clone(),
1437                position: position.clone(),
1438                detail: other.to_string(),
1439            }),
1440        }
1441    }
1442    mapped
1443}
1444
1445/// Push every [`SchemaType::Ref`] id written structurally in `ty` (descending
1446/// through all inline constructor children, but not following the refs into
1447/// definition bodies) into `out`. This mirrors the set of refs the use-site
1448/// well-formedness check walks, so it can seed pure-alias suppression.
1449fn collect_structural_ref_ids(ty: &SchemaType, out: &mut HashSet<TypeId>) {
1450    match ty {
1451        SchemaType::Ref { id, .. } => {
1452            out.insert(id.clone());
1453        }
1454        SchemaType::Record { fields, .. } => {
1455            for field in fields {
1456                collect_structural_ref_ids(&field.body, out);
1457            }
1458        }
1459        SchemaType::Variant { cases, .. } => {
1460            for case in cases {
1461                if let Some(payload) = &case.payload {
1462                    collect_structural_ref_ids(payload, out);
1463                }
1464            }
1465        }
1466        SchemaType::Tuple { elements, .. } => {
1467            for element in elements {
1468                collect_structural_ref_ids(element, out);
1469            }
1470        }
1471        SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
1472            collect_structural_ref_ids(element, out);
1473        }
1474        SchemaType::Map { key, value, .. } => {
1475            collect_structural_ref_ids(key, out);
1476            collect_structural_ref_ids(value, out);
1477        }
1478        SchemaType::Option { inner, .. } => collect_structural_ref_ids(inner, out),
1479        SchemaType::Result { spec, .. } => {
1480            if let Some(ok) = &spec.ok {
1481                collect_structural_ref_ids(ok, out);
1482            }
1483            if let Some(err) = &spec.err {
1484                collect_structural_ref_ids(err, out);
1485            }
1486        }
1487        SchemaType::Union { spec, .. } => {
1488            for branch in &spec.branches {
1489                collect_structural_ref_ids(&branch.body, out);
1490            }
1491        }
1492        SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
1493            if let Some(inner) = inner {
1494                collect_structural_ref_ids(inner, out);
1495            }
1496        }
1497        _ => {}
1498    }
1499}
1500
1501/// The set of pure-alias definitions (a bare [`SchemaType::Ref`] body) reachable
1502/// from `seeds` by following only pure-alias edges. A use-site `resolve_ref`
1503/// walks exactly these chains, so any dangling/recursive-alias failure they
1504/// carry is already reported at the use site and must not be reported again
1505/// while validating these definitions' own bodies. The walk marks each
1506/// pure-alias definition it visits, stops at constructor bodies and missing
1507/// definitions (which are not marked, so they are still validated), and guards
1508/// against cycles.
1509fn pure_alias_closure(graph: &SchemaGraph, seeds: &HashSet<TypeId>) -> HashSet<TypeId> {
1510    let mut covered = HashSet::new();
1511    for seed in seeds {
1512        let mut current = seed.clone();
1513        while let Some(def) = graph.lookup(&current) {
1514            let SchemaType::Ref { id, .. } = &def.body else {
1515                break;
1516            };
1517            if !covered.insert(current.clone()) {
1518                break;
1519            }
1520            current = id.clone();
1521        }
1522    }
1523    covered
1524}
1525
1526/// Flatten every [`Ref`] referenced by a constraint, regardless of nesting.
1527fn collect_refs(constraint: &Constraint) -> Vec<&Ref> {
1528    match constraint {
1529        Constraint::RequiresAll(refs)
1530        | Constraint::AllOrNone(refs)
1531        | Constraint::RequiresAny(refs) => refs.iter().collect(),
1532        Constraint::MutexGroups(groups) => groups.iter().flat_map(|g| g.refs.iter()).collect(),
1533        Constraint::Implies(implies) => implies.lhs.iter().chain(implies.rhs.iter()).collect(),
1534        Constraint::Forbids(forbids) => forbids.lhs.iter().chain(forbids.rhs.iter()).collect(),
1535    }
1536}
1537
1538#[cfg(test)]
1539mod tests;