diff --git a/crates/libgraphql-core-v1/src/lib.rs b/crates/libgraphql-core-v1/src/lib.rs index 9995966..4375d55 100644 --- a/crates/libgraphql-core-v1/src/lib.rs +++ b/crates/libgraphql-core-v1/src/lib.rs @@ -39,6 +39,7 @@ pub mod schema_source_map; pub mod span; pub mod type_builders; pub mod types; +pub(crate) mod validators; pub mod value; pub use crate::located::Located; diff --git a/crates/libgraphql-core-v1/src/located.rs b/crates/libgraphql-core-v1/src/located.rs index aa27937..30692de 100644 --- a/crates/libgraphql-core-v1/src/located.rs +++ b/crates/libgraphql-core-v1/src/located.rs @@ -17,7 +17,8 @@ use crate::span::Span; /// /// # Example /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::Located; /// use libgraphql_core::names::TypeName; /// use libgraphql_core::span::Span; diff --git a/crates/libgraphql-core-v1/src/names/directive_name.rs b/crates/libgraphql-core-v1/src/names/directive_name.rs index 68e97cb..9dd484b 100644 --- a/crates/libgraphql-core-v1/src/names/directive_name.rs +++ b/crates/libgraphql-core-v1/src/names/directive_name.rs @@ -14,7 +14,8 @@ use std::borrow::Borrow; /// /// # Construction /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::names::DirectiveName; /// /// let name = DirectiveName::new("deprecated"); diff --git a/crates/libgraphql-core-v1/src/names/enum_value_name.rs b/crates/libgraphql-core-v1/src/names/enum_value_name.rs index 58fb1db..a58768d 100644 --- a/crates/libgraphql-core-v1/src/names/enum_value_name.rs +++ b/crates/libgraphql-core-v1/src/names/enum_value_name.rs @@ -13,7 +13,8 @@ use std::borrow::Borrow; /// /// # Construction /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::names::EnumValueName; /// /// let name = EnumValueName::new("ACTIVE"); diff --git a/crates/libgraphql-core-v1/src/names/field_name.rs b/crates/libgraphql-core-v1/src/names/field_name.rs index 9a792c6..57b1781 100644 --- a/crates/libgraphql-core-v1/src/names/field_name.rs +++ b/crates/libgraphql-core-v1/src/names/field_name.rs @@ -14,7 +14,8 @@ use std::borrow::Borrow; /// /// # Construction /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::names::FieldName; /// /// let name = FieldName::new("firstName"); diff --git a/crates/libgraphql-core-v1/src/names/fragment_name.rs b/crates/libgraphql-core-v1/src/names/fragment_name.rs index 4bac508..e0ca7fb 100644 --- a/crates/libgraphql-core-v1/src/names/fragment_name.rs +++ b/crates/libgraphql-core-v1/src/names/fragment_name.rs @@ -13,7 +13,8 @@ use std::borrow::Borrow; /// /// # Construction /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::names::FragmentName; /// /// let name = FragmentName::new("UserFields"); diff --git a/crates/libgraphql-core-v1/src/names/type_name.rs b/crates/libgraphql-core-v1/src/names/type_name.rs index 96d010e..8306953 100644 --- a/crates/libgraphql-core-v1/src/names/type_name.rs +++ b/crates/libgraphql-core-v1/src/names/type_name.rs @@ -13,7 +13,8 @@ use std::borrow::Borrow; /// /// # Construction /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::names::TypeName; /// /// let name = TypeName::new("User"); diff --git a/crates/libgraphql-core-v1/src/names/variable_name.rs b/crates/libgraphql-core-v1/src/names/variable_name.rs index 1f064bf..f6dec47 100644 --- a/crates/libgraphql-core-v1/src/names/variable_name.rs +++ b/crates/libgraphql-core-v1/src/names/variable_name.rs @@ -14,7 +14,8 @@ use std::borrow::Borrow; /// /// # Construction /// -/// ```ignore +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; /// use libgraphql_core::names::VariableName; /// /// let name = VariableName::new("userId"); diff --git a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs index a051782..2aee4a9 100644 --- a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs +++ b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs @@ -70,10 +70,17 @@ fn builtin_directives_seeded() { assert!(deprecated.is_builtin()); assert_eq!(deprecated.parameters().len(), 1); assert!(deprecated.parameters().contains_key("reason")); - // Verify default value + // Verify default value and nullability. + // The `reason` parameter must be non-nullable (String!) per + // the September 2025 spec. A previous bug had it set to + // nullable (true) instead of non-nullable (false). let reason_param = deprecated.parameters().get("reason") .expect("reason param not found"); assert!(reason_param.default_value().is_some()); + assert!( + !reason_param.type_annotation().nullable(), + "@deprecated reason must be non-nullable (String!)", + ); let specified_by = defs.get("specifiedBy") .expect("@specifiedBy not found"); @@ -400,3 +407,41 @@ fn load_str_all_type_kinds() { assert!(sb.types().contains_key(&TypeName::new("DateTime"))); assert!(sb.types().contains_key(&TypeName::new("CreateInput"))); } + +// Regression test for a bug where parse errors returned by +// SchemaBuilder::load_str() carried un-translated spans +// (Span::builtin() with BUILTIN_SOURCE_MAP_ID) instead of +// spans pointing at the source map that was allocated for +// the loaded string. The effect was that diagnostics emitted +// for parse errors were effectively un-locatable in tooling +// because they did not reference the actual input source. +// This test asserts that load_str() now translates parse +// error spans so their source_map_id points at the loaded +// source. +// +// Written by Claude Code, reviewed by a human. +#[test] +fn load_str_parse_error_has_proper_span() { + let mut sb = SchemaBuilder::new(); + let result = sb.load_str("type { broken }"); + assert!(result.is_err()); + let errors = match result { + Err(errs) => errs, + Ok(_) => panic!("expected parse error"), + }; + assert!(!errors.is_empty()); + assert!(matches!( + errors[0].kind(), + SchemaBuildErrorKind::ParseError { .. }, + )); + // The span's source_map_id must NOT be the built-in id + // (SourceMapId(0)). It should point to the source map + // created for the loaded string. + let span = errors[0].span(); + assert_ne!( + span.source_map_id, + crate::span::BUILTIN_SOURCE_MAP_ID, + "parse error span should reference the loaded source, \ + not Span::builtin()", + ); +} diff --git a/crates/libgraphql-core-v1/src/schema/tests/type_validation_error_tests.rs b/crates/libgraphql-core-v1/src/schema/tests/type_validation_error_tests.rs index 703263d..8ce3357 100644 --- a/crates/libgraphql-core-v1/src/schema/tests/type_validation_error_tests.rs +++ b/crates/libgraphql-core-v1/src/schema/tests/type_validation_error_tests.rs @@ -19,7 +19,7 @@ fn circular_input_field_chain_display() { vec![], ); let msg = error.to_string(); - assert!(msg.contains("A -> B -> C"), "got: {msg}"); + assert!(msg.contains("`A` -> `B` -> `C`"), "got: {msg}"); assert!(msg.contains("circular"), "got: {msg}"); } diff --git a/crates/libgraphql-core-v1/src/schema/type_validation_error.rs b/crates/libgraphql-core-v1/src/schema/type_validation_error.rs index 45cc815..f543b30 100644 --- a/crates/libgraphql-core-v1/src/schema/type_validation_error.rs +++ b/crates/libgraphql-core-v1/src/schema/type_validation_error.rs @@ -59,7 +59,7 @@ impl std::error::Error for TypeValidationError { pub enum TypeValidationErrorKind { #[error( "circular non-nullable input field chain: {}", - circular_field_path.join(" -> "), + circular_field_path.iter().map(|t| format!("`{t}`")).collect::>().join(" -> "), )] CircularInputFieldChain { circular_field_path: Vec, @@ -85,6 +85,16 @@ pub enum TypeValidationErrorKind { undefined_interface_name: String, }, + #[error( + "parameter `{parameter_name}` on directive `@{directive_name}` \ + has type `{invalid_type_name}` which is not an input type" + )] + InvalidDirectiveParameterType { + directive_name: String, + invalid_type_name: String, + parameter_name: String, + }, + #[error( "input field `{parent_type_name}.{field_name}` has \ type `{invalid_type_name}` which is not an input type" diff --git a/crates/libgraphql-core-v1/src/type_builders/tests/builder_validation_tests.rs b/crates/libgraphql-core-v1/src/type_builders/tests/builder_validation_tests.rs index 972f7d2..5e2d2ff 100644 --- a/crates/libgraphql-core-v1/src/type_builders/tests/builder_validation_tests.rs +++ b/crates/libgraphql-core-v1/src/type_builders/tests/builder_validation_tests.rs @@ -306,6 +306,43 @@ fn directive_add_parameter_rejects_duplicate() { )); } +// Regression test for a bug where +// DirectiveBuilder::add_parameter() emitted the wrong error +// kind when rejecting a `__`-prefixed parameter name. It +// previously returned +// SchemaBuildErrorKind::InvalidDunderPrefixedDirectiveName +// (which describes an invalid directive NAME) when the actual +// problem was the PARAMETER name; the correct variant is +// SchemaBuildErrorKind::InvalidDunderPrefixedParamName. This +// test asserts the corrected behavior so the wrong-variant bug +// cannot reappear. +// +// https://spec.graphql.org/September2025/#sec-Names.Reserved-Names +// Written by Claude Code, reviewed by a human. +#[test] +fn directive_add_parameter_rejects_dunder_prefix() { + let mut builder = DirectiveBuilder::new( + "myDirective", Span::builtin(), + ).unwrap(); + builder.add_location(DirectiveLocationKind::FieldDefinition); + let err = builder.add_parameter(ParameterDefBuilder::new( + "__bad", + TypeAnnotation::named("String", /* nullable = */ true), + Span::builtin(), + )).unwrap_err(); + assert!( + matches!( + err.kind(), + SchemaBuildErrorKind::InvalidDunderPrefixedParamName { + param_name, + .. + } if param_name == "__bad" + ), + "expected InvalidDunderPrefixedParamName, got: {:?}", + err.kind(), + ); +} + // Verifies FieldDefBuilder::add_parameter() rejects duplicates. // https://spec.graphql.org/September2025/#sec-Field-Arguments.Type-Validation // Written by Claude Code, reviewed by a human. diff --git a/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs b/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs new file mode 100644 index 0000000..e0c6277 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/directive_definition_validator.rs @@ -0,0 +1,86 @@ +use crate::error_note::ErrorNote; +use crate::names::DirectiveName; +use crate::names::TypeName; +use crate::schema::TypeValidationError; +use crate::schema::TypeValidationErrorKind; +use crate::types::DirectiveDefinition; +use crate::types::GraphQLType; +use crate::validators::edit_distance::find_similar_names; +use indexmap::IndexMap; + +/// Validates custom directive definitions. +/// +/// Checks that every parameter on a custom (non-builtin) directive +/// definition references a valid input type. Built-in directives +/// are skipped since they are validated by the spec itself. +/// +/// See [Type System Directives](https://spec.graphql.org/September2025/#sec-Type-System.Directives). +pub(crate) fn validate_directive_definitions( + directive_defs: &IndexMap, + types_map: &IndexMap, +) -> Vec { + let mut errors = Vec::new(); + + for directive_def in directive_defs.values() { + // Only validate custom directives; built-in directives + // are spec-defined and assumed correct. + if directive_def.is_builtin() { + continue; + } + + for (param_name, param) in directive_def.parameters() { + let innermost_type_name = + param.type_annotation().innermost_type_name(); + let innermost_type = + types_map.get(innermost_type_name); + + if let Some(innermost_type) = innermost_type { + // All directive parameters must be declared with + // an input type. + // + // https://spec.graphql.org/September2025/#sec-Type-System.Directives + if !innermost_type.is_input_type() { + errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidDirectiveParameterType { + directive_name: + directive_def.name().to_string(), + invalid_type_name: + innermost_type_name.to_string(), + parameter_name: + param_name.to_string(), + }, + param.type_annotation().span(), + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec-Type-System.Directives", + )], + )); + } + } else { + // https://spec.graphql.org/September2025/#sec-Type-System.Directives + let mut notes = Vec::new(); + let suggestions = find_similar_names( + innermost_type_name.as_str(), + types_map.keys(), + ); + if let Some(best) = suggestions.first() { + notes.push(ErrorNote::help( + format!("did you mean `{best}`?"), + )); + } + notes.push(ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec-Types", + )); + errors.push(TypeValidationError::new( + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name: + innermost_type_name.to_string(), + }, + param.type_annotation().span(), + notes, + )); + } + } + } + + errors +} diff --git a/crates/libgraphql-core-v1/src/validators/edit_distance/mod.rs b/crates/libgraphql-core-v1/src/validators/edit_distance/mod.rs new file mode 100644 index 0000000..a62202b --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/edit_distance/mod.rs @@ -0,0 +1,78 @@ +use crate::names::TypeName; + +/// Finds type names in `candidates` that are within a reasonable +/// edit distance of `name`. Returns at most 3 suggestions, +/// sorted by distance (best first). +/// +/// The distance threshold is adaptive: `name.len() / 3 + 1`. +/// Shorter names require closer matches to avoid nonsensical +/// suggestions. +pub(crate) fn find_similar_names<'a>( + name: &str, + candidates: impl Iterator, +) -> Vec<&'a TypeName> { + let max_distance = name.len() / 3 + 1; + let mut scored: Vec<(usize, &'a TypeName)> = candidates + .filter_map(|candidate| { + let dist = + levenshtein_distance(name, candidate.as_str()); + if dist > 0 && dist <= max_distance { + Some((dist, candidate)) + } else { + None + } + }) + .collect(); + + scored.sort_by(|(d1, n1), (d2, n2)| { + d1.cmp(d2).then_with(|| n1.cmp(n2)) + }); + scored + .into_iter() + .take(3) + .map(|(_, name)| name) + .collect() +} + +/// Computes the Levenshtein edit distance between two strings. +/// +/// Uses the classic dynamic-programming algorithm with O(min(a, +/// b)) space via a single-row buffer. +fn levenshtein_distance(a: &str, b: &str) -> usize { + let mut a_chars: Vec = a.chars().collect(); + let mut b_chars: Vec = b.chars().collect(); + + // Ensure `b_chars` is the shorter side for space efficiency. + if a_chars.len() < b_chars.len() { + std::mem::swap(&mut a_chars, &mut b_chars); + } + + let a_len = a_chars.len(); + let b_len = b_chars.len(); + + let mut prev_row: Vec = + (0..=b_len).collect(); + let mut curr_row: Vec = + vec![0; b_len + 1]; + + for i in 1..=a_len { + curr_row[0] = i; + for j in 1..=b_len { + let cost = + if a_chars[i - 1] == b_chars[j - 1] { + 0 + } else { + 1 + }; + curr_row[j] = (prev_row[j] + 1) + .min(curr_row[j - 1] + 1) + .min(prev_row[j - 1] + cost); + } + std::mem::swap(&mut prev_row, &mut curr_row); + } + + prev_row[b_len] +} + +#[cfg(test)] +mod tests; diff --git a/crates/libgraphql-core-v1/src/validators/edit_distance/tests.rs b/crates/libgraphql-core-v1/src/validators/edit_distance/tests.rs new file mode 100644 index 0000000..bb2f46c --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/edit_distance/tests.rs @@ -0,0 +1,126 @@ +use crate::names::TypeName; +use crate::validators::edit_distance::find_similar_names; +use crate::validators::edit_distance::levenshtein_distance; + +// Verifies that two identical strings have an edit distance of 0. +// Written by Claude Code, reviewed by a human. +#[test] +fn exact_match_is_distance_zero() { + assert_eq!(levenshtein_distance("String", "String"), 0); + assert_eq!(levenshtein_distance("", ""), 0); + assert_eq!(levenshtein_distance("a", "a"), 0); +} + +// Verifies that a single-character substitution produces an +// edit distance of 1. +// Written by Claude Code, reviewed by a human. +#[test] +fn single_char_substitution_is_distance_one() { + assert_eq!(levenshtein_distance("String", "Strung"), 1); + assert_eq!(levenshtein_distance("cat", "bat"), 1); + assert_eq!(levenshtein_distance("abc", "adc"), 1); +} + +// Verifies that insertions and deletions are counted correctly. +// Written by Claude Code, reviewed by a human. +#[test] +fn insertion_and_deletion() { + // Deletion: "String" -> "Sting" (remove 'r') + assert_eq!(levenshtein_distance("String", "Sting"), 1); + // Insertion: "Sting" -> "String" (add 'r') + assert_eq!(levenshtein_distance("Sting", "String"), 1); + // Multiple operations + assert_eq!(levenshtein_distance("kitten", "sitting"), 3); + // Empty vs non-empty + assert_eq!(levenshtein_distance("", "abc"), 3); + assert_eq!(levenshtein_distance("abc", ""), 3); +} + +// Verifies that find_similar_names returns the best matches +// sorted by edit distance, limited to at most 3 results. +// Written by Claude Code, reviewed by a human. +#[test] +fn find_similar_names_returns_best_matches() { + let candidates = [ + TypeName::new("String"), + TypeName::new("Int"), + TypeName::new("Float"), + TypeName::new("Boolean"), + TypeName::new("Strong"), + ]; + let results = find_similar_names( + "Strng", + candidates.iter(), + ); + // "String" (distance 1) and "Strong" (distance 2) should + // match; "Int", "Float", "Boolean" are too far. + assert!(!results.is_empty()); + assert_eq!( + results[0].as_str(), "String", + "best match for 'Strng' should be 'String'", + ); +} + +// Verifies that find_similar_names returns an empty vec when +// no candidates are within the adaptive distance threshold. +// Written by Claude Code, reviewed by a human. +#[test] +fn find_similar_names_returns_empty_for_very_different_names() { + let candidates = [ + TypeName::new("String"), + TypeName::new("Int"), + TypeName::new("Float"), + ]; + let results = find_similar_names( + "CompletelyUnrelated", + candidates.iter(), + ); + assert!( + results.is_empty(), + "expected no suggestions for a very different name, \ + got: {results:?}", + ); +} + +// Verifies that find_similar_names returns at most 3 +// suggestions even when more candidates match. +// Written by Claude Code, reviewed by a human. +#[test] +fn find_similar_names_limits_to_three() { + let candidates = [ + TypeName::new("Aa"), + TypeName::new("Ab"), + TypeName::new("Ac"), + TypeName::new("Ad"), + TypeName::new("Ae"), + ]; + let results = find_similar_names( + "Ax", + candidates.iter(), + ); + assert!( + results.len() <= 3, + "expected at most 3 suggestions, got: {}", + results.len(), + ); +} + +// Verifies that find_similar_names excludes exact matches +// (distance 0) since the caller is looking for *similar* but +// *different* names. +// Written by Claude Code, reviewed by a human. +#[test] +fn find_similar_names_excludes_exact_match() { + let candidates = [ + TypeName::new("String"), + TypeName::new("Strng"), + ]; + let results = find_similar_names( + "String", + candidates.iter(), + ); + // "String" itself (distance 0) should be excluded; only + // "Strng" (distance 1) should appear. + assert_eq!(results.len(), 1); + assert_eq!(results[0].as_str(), "Strng"); +} diff --git a/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs b/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs new file mode 100644 index 0000000..e6381b6 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/input_object_type_validator.rs @@ -0,0 +1,174 @@ +use crate::error_note::ErrorNote; +use crate::names::FieldName; +use crate::names::TypeName; +use crate::schema::TypeValidationError; +use crate::schema::TypeValidationErrorKind; +use crate::types::GraphQLType; +use crate::types::InputField; +use crate::types::InputObjectType; +use crate::types::TypeAnnotation; +use crate::validators::edit_distance::find_similar_names; +use indexmap::IndexMap; +use std::collections::HashSet; + +/// Validates an input object type's field type references, +/// input-type legality, and circular non-nullable reference +/// chains. +/// +/// Per the GraphQL spec, all input object fields must reference +/// valid input types (scalars, enums, or other input objects) and +/// input object types must not form non-nullable circular +/// references (which would make them impossible to construct). +/// +/// See [Input Objects](https://spec.graphql.org/September2025/#sec-Input-Objects). +pub(crate) struct InputObjectTypeValidator<'a> { + errors: Vec, + type_: &'a InputObjectType, + types_map: &'a IndexMap, +} + +impl<'a> InputObjectTypeValidator<'a> { + pub fn new( + type_: &'a InputObjectType, + types_map: &'a IndexMap, + ) -> Self { + Self { + errors: vec![], + type_, + types_map, + } + } + + pub fn validate(mut self) -> Vec { + let fields = self.type_.fields(); + self.validate_fields_recursive( + self.type_.name(), + fields, + &mut vec![], + HashSet::from([self.type_.name()]), + ); + self.errors + } + + fn validate_fields_recursive( + &mut self, + type_name: &'a TypeName, + fields: &'a IndexMap, + path: &mut Vec<(&'a TypeName, Option<&'a FieldName>)>, + seen_type_names: HashSet<&'a TypeName>, + ) { + for (field_name, field) in fields { + let type_annot = field.type_annotation(); + let innermost_type_name = + type_annot.innermost_type_name(); + let innermost_type = + self.types_map.get(innermost_type_name); + + let innermost_type = + if let Some(innermost_type) = innermost_type { + // Input object fields must not use non-input + // types (Object, Interface, Union are output-only). + // + // https://spec.graphql.org/September2025/#sel-IAHhBXDDBFCAACEB4iG + if !innermost_type.is_input_type() { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidInputFieldWithOutputType { + field_name: + field_name.to_string(), + invalid_type_name: + innermost_type_name.to_string(), + parent_type_name: + type_name.to_string(), + }, + field.type_annotation().span(), + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#sel-IAHhBXDDBFCAACEB4iG", + )], + )); + } + + innermost_type + } else { + // https://spec.graphql.org/September2025/#sec-Input-Objects + let mut notes = Vec::new(); + let suggestions = find_similar_names( + innermost_type_name.as_str(), + self.types_map.keys(), + ); + if let Some(best) = suggestions.first() { + notes.push(ErrorNote::help( + format!("did you mean `{best}`?"), + )); + } + notes.push(ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec-Types", + )); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name: + innermost_type_name.to_string(), + }, + field.type_annotation().span(), + notes, + )); + continue; + }; + + // Look for input-type cycles that aren't broken by + // at least one nullable type. + let is_cycle_breaking = + annot_breaks_circular_chain( + field.type_annotation(), + ); + if !is_cycle_breaking { + path.extend_from_slice(&[ + (type_name, Some(field_name)), + (innermost_type_name, None), + ]); + if seen_type_names.contains(innermost_type_name) { + // https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::CircularInputFieldChain { + circular_field_path: path + .iter() + .map(|(tn, fn_opt)| { + if let Some(fn_) = fn_opt { + format!("{tn}.{fn_}") + } else { + format!("{tn}") + } + }) + .collect(), + }, + field.type_annotation().span(), + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation", + )], + )); + } else if let GraphQLType::InputObject(input_obj_type) = + innermost_type + { + let mut seen_type_names = seen_type_names.clone(); + seen_type_names.insert(innermost_type_name); + self.validate_fields_recursive( + innermost_type_name, + input_obj_type.fields(), + path, + seen_type_names, + ); + } + path.pop(); + path.pop(); + } + } + } +} + +fn annot_breaks_circular_chain( + type_annot: &TypeAnnotation, +) -> bool { + match type_annot { + TypeAnnotation::List(_) => true, + TypeAnnotation::Named(named_annot) => named_annot.nullable(), + } +} diff --git a/crates/libgraphql-core-v1/src/validators/mod.rs b/crates/libgraphql-core-v1/src/validators/mod.rs new file mode 100644 index 0000000..6810cd2 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/mod.rs @@ -0,0 +1,19 @@ +/// Type-system validators for cross-type validation. +/// +/// These validators are designed to be called from +/// `SchemaBuilder::build()` (Task 16) to enforce the GraphQL +/// specification's type-system rules. They are not yet wired +/// into the build pipeline — `build()` is currently `todo!()`. +mod directive_definition_validator; +mod edit_distance; +mod input_object_type_validator; +mod object_or_interface_type_validator; +mod union_type_validator; + +pub(crate) use crate::validators::directive_definition_validator::validate_directive_definitions; +pub(crate) use crate::validators::input_object_type_validator::InputObjectTypeValidator; +pub(crate) use crate::validators::object_or_interface_type_validator::ObjectOrInterfaceTypeValidator; +pub(crate) use crate::validators::union_type_validator::UnionTypeValidator; + +#[cfg(test)] +mod tests; diff --git a/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs b/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs new file mode 100644 index 0000000..e1d8d49 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/object_or_interface_type_validator.rs @@ -0,0 +1,608 @@ +use crate::error_note::ErrorNote; +use crate::names::TypeName; +use crate::schema::TypeValidationError; +use crate::schema::TypeValidationErrorKind; +use crate::types::GraphQLType; +use crate::types::HasFieldsAndInterfaces; +use crate::types::InterfaceType; +use crate::validators::edit_distance::find_similar_names; +use indexmap::IndexMap; +use std::collections::HashSet; + +/// Validates an object or interface type's interface +/// implementations, field output-type legality, and parameter +/// input-type legality. +/// +/// Implements the +/// [IsValidImplementation](https://spec.graphql.org/September2025/#IsValidImplementation()) +/// algorithm from the GraphQL specification. +/// +/// # Validation phases +/// +/// The validator runs three distinct phases in order: +/// +/// 1. **Transitive interface completeness** — For each +/// directly-declared interface `I`, verifies that `I` exists +/// and is an interface type, then walks `I`'s own transitive +/// interface chain to ensure the implementing type also +/// declares every transitively-required interface. +/// +/// 2. **Field contract validation** — For each directly-declared +/// interface `I`, validates the field contract: every +/// interface field must exist on the implementing type with +/// matching parameters (equivalence) and a covariant return +/// type. Additional parameters must be optional. Uses a +/// separate dedup set so that an interface's field contract +/// is checked exactly once even if multiple declared +/// interfaces share a transitive ancestor. +/// +/// 3. **Field type/param checks** — For ALL fields on the +/// implementing type (including non-interface fields), +/// validates that return types are output types and parameter +/// types are input types. +/// +/// Each phase uses its own local state, avoiding the +/// shared-state bug where phase 1's transitive walk could +/// prevent phase 2 from validating directly-declared +/// interfaces. +pub(crate) struct ObjectOrInterfaceTypeValidator<'a, T: HasFieldsAndInterfaces> { + errors: Vec, + type_: &'a T, + types_map: &'a IndexMap, +} + +impl<'a, T: HasFieldsAndInterfaces> ObjectOrInterfaceTypeValidator<'a, T> { + pub fn new( + type_: &'a T, + types_map: &'a IndexMap, + ) -> Self { + Self { + errors: vec![], + type_, + types_map, + } + } + + pub fn validate(mut self) -> Vec { + self.check_interface_completeness(); + self.check_field_contracts(); + self.check_field_types(); + self.errors + } + + /// Phase 1: Transitive interface completeness. + /// + /// For each interface the type directly declares in + /// `implements`, verifies: + /// - The interface name resolves to a defined type + /// - That type is actually an interface + /// - Every transitively-required interface (from the + /// interface's own chain) is also directly declared by + /// the implementing type + /// + /// Uses a LOCAL visited set for the transitive walk, fully + /// independent from the field-contract phase. + /// + /// https://spec.graphql.org/September2025/#IsValidImplementation() + fn check_interface_completeness(&mut self) { + let type_name = self.type_.name(); + let implemented_iface_names: HashSet<&TypeName> = self + .type_ + .interfaces() + .iter() + .map(|l| &l.value) + .collect(); + + for located_iface in self.type_.interfaces() { + let iface_name = &located_iface.value; + + // Verify that this implemented interface name is + // actually a defined type. + let Some(iface_type) = self.types_map.get(iface_name) else { + let mut notes = Vec::new(); + let suggestions = find_similar_names( + iface_name.as_str(), + self.types_map.keys(), + ); + if let Some(best) = suggestions.first() { + notes.push(ErrorNote::help( + format!("did you mean `{best}`?"), + )); + } + notes.push(ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + )); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::ImplementsUndefinedInterface { + type_name: type_name.to_string(), + undefined_interface_name: iface_name.to_string(), + }, + located_iface.span, + notes, + )); + continue; + }; + + // Verify that the defined type being implemented is + // an interface type. + let Some(iface) = iface_type.as_interface() else { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::ImplementsNonInterfaceType { + type_name: type_name.to_string(), + non_interface_type_name: iface_name.to_string(), + }, + located_iface.span, + vec![ + ErrorNote::general_with_span( + format!("`{iface_name}` is defined here"), + iface_type.span(), + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + ), + ], + )); + continue; + }; + + // Walk the interface's own transitive chain and + // collect all transitively-required interfaces. + let mut transitive = HashSet::new(); + self.collect_transitive_interfaces(iface, &mut transitive); + + // For each transitively-required interface, check + // that the implementing type also directly declares + // it. + for required_iface_name in &transitive { + if !implemented_iface_names.contains(*required_iface_name) { + // Build an inheritance path from the + // directly-declared interface down to the + // interface that transitively requires + // `required_iface_name`. + let inheritance_path = self.build_inheritance_path( + iface, + required_iface_name, + ); + + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { + inheritance_path, + missing_recursive_interface_name: + required_iface_name.to_string(), + type_name: type_name.to_string(), + }, + located_iface.span, + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + )], + )); + } + } + } + } + + /// Phase 2: Field contract validation. + /// + /// For each directly-declared interface, validates that the + /// implementing type satisfies the interface's field contract + /// per IsValidImplementation(): + /// - Every interface field must exist on the type + /// - Parameter equivalence (same params, same types) + /// - Additional params must be optional + /// - Return type must be a covariant subtype + /// + /// Uses a separate `field_validated_interfaces` set to avoid + /// checking the same interface's fields twice (e.g. when + /// multiple declared interfaces share a common ancestor). + /// + /// https://spec.graphql.org/September2025/#IsValidImplementation() + fn check_field_contracts(&mut self) { + let type_name = self.type_.name(); + let type_fields = self.type_.fields(); + let type_span = self.type_.span(); + let mut field_validated_interfaces: HashSet<&TypeName> = HashSet::new(); + + for located_iface in self.type_.interfaces() { + let iface_name = &located_iface.value; + + // Skip if we can't resolve to a valid interface (phase 1 + // already reported these errors). + let Some(iface_type) = self.types_map.get(iface_name) else { + continue; + }; + let Some(iface) = iface_type.as_interface() else { + continue; + }; + + // Dedup: skip if we already validated this + // interface's field contract. + if !field_validated_interfaces.insert(iface_name) { + continue; + } + + let iface_fields = iface.fields(); + for (field_name, iface_field) in iface_fields { + let Some(type_field) = type_fields.get(field_name) else { + // The implementing type must define every + // field the interface declares. + // + // https://spec.graphql.org/September2025/#IsValidImplementation() + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::MissingInterfaceSpecifiedField { + field_name: field_name.to_string(), + interface_name: iface_name.to_string(), + type_name: type_name.to_string(), + }, + type_span, + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + )], + )); + continue; + }; + + let iface_field_params = iface_field.parameters(); + let type_field_params = type_field.parameters(); + + // For each parameter defined on this field in + // the interface, there must be a corresponding + // and equivalently-typed parameter on the + // implementing type. + // + // https://spec.graphql.org/September2025/#IsValidImplementation() + for (param_name, iface_field_param) in iface_field_params { + let Some(type_param) = type_field_params.get(param_name) else { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::MissingInterfaceSpecifiedFieldParameter { + field_name: field_name.to_string(), + interface_name: iface_name.to_string(), + missing_parameter_name: param_name.to_string(), + type_name: type_name.to_string(), + }, + type_field.span(), + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + )], + )); + continue; + }; + + let iface_param_type = iface_field_param.type_annotation(); + let type_param_type = type_param.type_annotation(); + if !type_param_type.is_equivalent_to(iface_param_type) { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidInterfaceSpecifiedFieldParameterType { + actual_type: type_param_type.to_string(), + expected_type: iface_param_type.to_string(), + field_name: field_name.to_string(), + interface_name: iface_name.to_string(), + parameter_name: param_name.to_string(), + type_name: type_name.to_string(), + }, + type_param.span(), + vec![ + ErrorNote::general_with_span( + format!( + "interface defines this \ + parameter as `{iface_param_type}`", + ), + iface_field_param.span(), + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + ), + ], + )); + } + } + + // Any parameters defined on the implementing + // field which aren't also defined on the + // interface's corresponding field must be + // optional (either nullable or defined with a + // default value). + // + // See step 2.d at + // https://spec.graphql.org/September2025/#IsValidImplementation() + let iface_field_param_names: HashSet<_> = + iface_field_params.keys().collect(); + let type_field_param_names: HashSet<_> = + type_field_params.keys().collect(); + let additional_field_param_names = + type_field_param_names.difference(&iface_field_param_names); + + for additional_param_name in additional_field_param_names { + let additional_param = type_field_params + .get(*additional_param_name) + .unwrap(); + let additional_param_annot = additional_param.type_annotation(); + + let is_nullable = additional_param_annot.nullable(); + let has_default = additional_param.default_value().is_some(); + if !is_nullable && !has_default { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidRequiredAdditionalParameterOnInterfaceSpecifiedField { + field_name: field_name.to_string(), + interface_name: iface_name.to_string(), + parameter_name: additional_param_name.to_string(), + type_name: type_name.to_string(), + }, + additional_param.span(), + vec![ + ErrorNote::general_with_span( + "field definition on implemented interface", + iface_field.span(), + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + ), + ], + )); + } + } + + // Field return types must be covariant subtypes. + // + // https://spec.graphql.org/September2025/#IsValidImplementation() + let type_field_annot = type_field.type_annotation(); + let iface_field_annot = iface_field.type_annotation(); + if !type_field_annot.is_subtype_of(self.types_map, iface_field_annot) { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidInterfaceSpecifiedFieldType { + actual_type: type_field_annot.to_string(), + expected_type: iface_field_annot.to_string(), + field_name: field_name.to_string(), + interface_name: iface_name.to_string(), + type_name: type_name.to_string(), + }, + type_field.span(), + vec![ + ErrorNote::general_with_span( + format!( + "interface field has return \ + type `{iface_field_annot}`", + ), + iface_field.span(), + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#IsValidImplementation()", + ), + ], + )); + } + + // TODO: IsValidImplementation step 2.f -- if the interface field + // is NOT deprecated, the implementing field must also NOT be + // deprecated. This check is deferred until DeprecationState is + // queryable from FieldDefinition. + // https://spec.graphql.org/September2025/#IsValidImplementation() + } + } + } + + /// Phase 3: Field type/param checks for ALL fields. + /// + /// Independent of interface validation — validates that every + /// field on the implementing type (including non-interface + /// fields) uses valid output types for return values and + /// valid input types for parameters. + /// + /// https://spec.graphql.org/September2025/#sel-JAHZhCFDBFABLBgB_pM + /// https://spec.graphql.org/September2025/#sel-KAHZhCFDBHBDCAACEB6yD + fn check_field_types(&mut self) { + let type_name = self.type_.name(); + let type_fields = self.type_.fields(); + + for (field_name, field) in type_fields { + let innermost_type_name = field.type_annotation().innermost_type_name(); + let innermost_type = self.types_map.get(innermost_type_name); + + if let Some(innermost_type) = innermost_type { + // All fields on an object/interface type must be + // declared with an output type. + // + // https://spec.graphql.org/September2025/#sel-JAHZhCFDBFABLBgB_pM + if !innermost_type.is_output_type() { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidOutputFieldWithInputType { + field_name: field_name.to_string(), + input_type_name: innermost_type_name.to_string(), + parent_type_name: type_name.to_string(), + }, + field.type_annotation().span(), + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#sel-JAHZhCFDBFABLBgB_pM", + )], + )); + } + } else { + // https://spec.graphql.org/September2025/#sec-Objects + let mut notes = Vec::new(); + let suggestions = find_similar_names( + innermost_type_name.as_str(), + self.types_map.keys(), + ); + if let Some(best) = suggestions.first() { + notes.push(ErrorNote::help( + format!("did you mean `{best}`?"), + )); + } + notes.push(ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec-Types", + )); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name: + innermost_type_name.to_string(), + }, + field.type_annotation().span(), + notes, + )); + } + + for (param_name, param) in field.parameters() { + let innermost_type_name = + param.type_annotation().innermost_type_name(); + let innermost_type = self.types_map.get(innermost_type_name); + + if let Some(innermost_type) = innermost_type { + // All parameters must be declared with an + // input type. + // + // https://spec.graphql.org/September2025/#sel-KAHZhCFDBHBDCAACEB6yD + if !innermost_type.is_input_type() { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidParameterWithOutputOnlyType { + field_name: field_name.to_string(), + invalid_type_name: + innermost_type_name.to_string(), + parameter_name: param_name.to_string(), + type_name: type_name.to_string(), + }, + param.type_annotation().span(), + vec![ErrorNote::spec( + "https://spec.graphql.org/September2025/#sel-KAHZhCFDBHBDCAACEB6yD", + )], + )); + } + } else { + // https://spec.graphql.org/September2025/#sec-Objects + let mut notes = Vec::new(); + let suggestions = find_similar_names( + innermost_type_name.as_str(), + self.types_map.keys(), + ); + if let Some(best) = suggestions.first() { + notes.push(ErrorNote::help( + format!("did you mean `{best}`?"), + )); + } + notes.push(ErrorNote::spec( + "https://spec.graphql.org/September2025/#sec-Types", + )); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name: + innermost_type_name.to_string(), + }, + param.type_annotation().span(), + notes, + )); + } + } + } + } + + /// Collects all interfaces transitively required by `iface` + /// into `result`. + /// + /// Walks `iface`'s own `interfaces()` list recursively, + /// accumulating every interface name encountered. Uses + /// `result` itself as a visited set to avoid infinite loops + /// in the presence of malformed cyclic schemas. + fn collect_transitive_interfaces( + &self, + iface: &'a InterfaceType, + result: &mut HashSet<&'a TypeName>, + ) { + for located_sub_iface in iface.interfaces() { + let sub_iface_name = &located_sub_iface.value; + + // If we've already seen this interface, skip to + // prevent infinite recursion on cyclic schemas. + if !result.insert(sub_iface_name) { + continue; + } + + // Recurse into the sub-interface's own chain. + let Some(sub_iface_type) = self.types_map.get(sub_iface_name) else { + continue; + }; + let Some(sub_iface) = sub_iface_type.as_interface() else { + continue; + }; + self.collect_transitive_interfaces(sub_iface, result); + } + } + + /// Builds the inheritance path from a directly-declared + /// interface down to the interface that transitively requires + /// `target_name`. + /// + /// Returns a vec of interface names representing the path, + /// e.g. for `Node -> Entity -> Root` where `Root` is the + /// target, returns `["Node", "Entity"]`. + fn build_inheritance_path( + &self, + start_iface: &'a InterfaceType, + target_name: &TypeName, + ) -> Vec { + let mut path = vec![start_iface.name().to_string()]; + + // If the start interface directly declares the target, + // the path is just [start_name]. + let directly_declares = start_iface + .interfaces() + .iter() + .any(|l| &l.value == target_name); + if directly_declares { + return path; + } + + // Otherwise, do a DFS to find the path to the + // interface that directly declares `target_name`. + let mut visited = HashSet::new(); + visited.insert(start_iface.name()); + if self.find_path_to_target(start_iface, target_name, &mut path, &mut visited) { + return path; + } + + // Fallback: shouldn't happen if collect_transitive_interfaces + // found target_name, but return what we have. + path + } + + /// DFS helper for `build_inheritance_path`. Returns true if + /// a path to an interface that directly declares + /// `target_name` was found. + fn find_path_to_target( + &self, + iface: &'a InterfaceType, + target_name: &TypeName, + path: &mut Vec, + visited: &mut HashSet<&'a TypeName>, + ) -> bool { + for located_sub_iface in iface.interfaces() { + let sub_iface_name = &located_sub_iface.value; + if !visited.insert(sub_iface_name) { + continue; + } + + let Some(sub_iface_type) = self.types_map.get(sub_iface_name) else { + continue; + }; + let Some(sub_iface) = sub_iface_type.as_interface() else { + continue; + }; + + // Check if this sub-interface directly declares + // target_name. + let sub_declares_target = sub_iface + .interfaces() + .iter() + .any(|l| &l.value == target_name); + if sub_declares_target { + path.push(sub_iface_name.to_string()); + return true; + } + + // Recurse deeper. + path.push(sub_iface_name.to_string()); + if self.find_path_to_target(sub_iface, target_name, path, visited) { + return true; + } + path.pop(); + } + false + } +} diff --git a/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs new file mode 100644 index 0000000..d97e793 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/tests/directive_definition_validator_tests.rs @@ -0,0 +1,292 @@ +use crate::names::DirectiveName; +use crate::names::FieldName; +use crate::names::TypeName; +use crate::schema::TypeValidationErrorKind; +use crate::span::Span; +use crate::types::DirectiveDefinition; +use crate::types::DirectiveDefinitionKind; +use crate::types::DirectiveLocationKind; +use crate::types::FieldedTypeData; +use crate::types::GraphQLType; +use crate::types::ObjectType; +use crate::types::ParameterDefinition; +use crate::types::ScalarKind; +use crate::types::ScalarType; +use crate::types::TypeAnnotation; +use crate::validators::validate_directive_definitions; +use indexmap::IndexMap; + +fn string_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::String, + name: TypeName::new("String"), + span: Span::builtin(), + })) +} + +fn make_param( + name: &str, + type_annot: TypeAnnotation, +) -> ParameterDefinition { + ParameterDefinition { + default_value: None, + description: None, + directives: vec![], + name: FieldName::new(name), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +// Verifies that a custom directive with valid input-type +// parameters produces no validation errors. +// https://spec.graphql.org/September2025/#sec-Type-System.Directives +// Written by Claude Code, reviewed by a human. +#[test] +fn valid_custom_directive_with_input_param() { + let mut params = IndexMap::new(); + params.insert( + FieldName::new("reason"), + make_param( + "reason", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("myDirective"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Custom, + locations: vec![DirectiveLocationKind::FieldDefinition], + name: DirectiveName::new("myDirective"), + parameters: params, + span: Span::dummy(), + }, + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies that a built-in directive is skipped during +// validation (built-ins are assumed correct per spec). +// https://spec.graphql.org/September2025/#sec-Type-System.Directives.Built-in-Directives +// Written by Claude Code, reviewed by a human. +#[test] +fn builtin_directive_skipped() { + let mut params = IndexMap::new(); + params.insert( + FieldName::new("reason"), + make_param( + "reason", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("deprecated"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Deprecated, + locations: vec![DirectiveLocationKind::FieldDefinition], + name: DirectiveName::new("deprecated"), + parameters: params, + span: Span::builtin(), + }, + ); + + // Even with an empty types_map (which would cause + // UndefinedTypeName for "String"), built-in directives are + // not validated. + let types_map = IndexMap::new(); + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies that a custom directive parameter referencing an +// output-only type (Object) produces an +// InvalidDirectiveParameterType error. +// https://spec.graphql.org/September2025/#sec-Type-System.Directives +// Written by Claude Code, reviewed by a human. +#[test] +fn directive_param_with_output_only_type() { + let result_obj = GraphQLType::Object(Box::new( + ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields: IndexMap::new(), + interfaces: vec![], + name: TypeName::new("Result"), + span: Span::dummy(), + }), + )); + + let mut params = IndexMap::new(); + params.insert( + FieldName::new("input"), + make_param( + "input", + TypeAnnotation::named("Result", /* nullable = */ true), + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("myDirective"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Custom, + locations: vec![DirectiveLocationKind::FieldDefinition], + name: DirectiveName::new("myDirective"), + parameters: params, + span: Span::dummy(), + }, + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("Result"), result_obj); + + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidDirectiveParameterType { + directive_name, + invalid_type_name, + parameter_name, + } if directive_name == "myDirective" + && invalid_type_name == "Result" + && parameter_name == "input" + )); +} + +// Verifies that a custom directive parameter referencing an +// undefined type produces an UndefinedTypeName error. +// https://spec.graphql.org/September2025/#sec-Type-System.Directives +// Written by Claude Code, reviewed by a human. +#[test] +fn directive_param_with_undefined_type() { + let mut params = IndexMap::new(); + params.insert( + FieldName::new("value"), + make_param( + "value", + TypeAnnotation::named( + "NonExistent", + /* nullable = */ true, + ), + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("tag"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Custom, + locations: vec![DirectiveLocationKind::Object], + name: DirectiveName::new("tag"), + parameters: params, + span: Span::dummy(), + }, + ); + + let types_map = IndexMap::new(); + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name, + } if undefined_type_name == "NonExistent" + )); +} + +// Verifies that the InvalidDirectiveParameterType error +// variant produces a sensible Display message that includes +// the directive name (with @), the parameter name, and the +// invalid type name. +// +// https://spec.graphql.org/September2025/#sec-Type-System.Directives +// Written by Claude Code, reviewed by a human. +#[test] +fn directive_param_output_type_error_display_is_sensible() { + let result_obj = GraphQLType::Object(Box::new( + ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields: IndexMap::new(), + interfaces: vec![], + name: TypeName::new("Result"), + span: Span::dummy(), + }), + )); + + let mut params = IndexMap::new(); + params.insert( + FieldName::new("input"), + make_param( + "input", + TypeAnnotation::named("Result", /* nullable = */ true), + ), + ); + let mut directive_defs = IndexMap::new(); + directive_defs.insert( + DirectiveName::new("myDirective"), + DirectiveDefinition { + description: None, + is_repeatable: false, + kind: DirectiveDefinitionKind::Custom, + locations: vec![DirectiveLocationKind::FieldDefinition], + name: DirectiveName::new("myDirective"), + parameters: params, + span: Span::dummy(), + }, + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("Result"), result_obj); + + let errors = validate_directive_definitions( + &directive_defs, + &types_map, + ); + assert_eq!(errors.len(), 1); + + let msg = errors[0].to_string(); + + // The message should clearly reference @myDirective, + // the parameter name, the invalid type, and say it's + // not an input type. + assert_eq!( + msg, + "parameter `input` on directive `@myDirective` \ + has type `Result` which is not an input type", + ); +} diff --git a/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs new file mode 100644 index 0000000..a522cb5 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/tests/input_object_type_validator_tests.rs @@ -0,0 +1,1000 @@ +use crate::names::FieldName; +use crate::names::TypeName; +use crate::schema::TypeValidationError; +use crate::schema::TypeValidationErrorKind; +use crate::span::Span; +use crate::types::FieldedTypeData; +use crate::types::GraphQLType; +use crate::types::InputField; +use crate::types::InputObjectType; +use crate::types::InterfaceType; +use crate::types::ObjectType; +use crate::types::ScalarKind; +use crate::types::ScalarType; +use crate::types::TypeAnnotation; +use crate::types::UnionType; +use crate::validators::InputObjectTypeValidator; +use indexmap::IndexMap; + +fn string_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::String, + name: TypeName::new("String"), + span: Span::builtin(), + })) +} + +fn int_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::Int, + name: TypeName::new("Int"), + span: Span::builtin(), + })) +} + +fn make_input_field( + name: &str, + parent: &str, + type_annot: TypeAnnotation, +) -> InputField { + InputField { + default_value: None, + description: None, + directives: vec![], + name: FieldName::new(name), + parent_type_name: TypeName::new(parent), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +// Verifies that a valid input object with only input-type fields +// produces no errors. +// https://spec.graphql.org/September2025/#sec-Input-Objects +// Written by Claude Code, reviewed by a human. +#[test] +fn valid_input_object_type() { + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("name"), + make_input_field( + "name", + "CreateUserInput", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + fields.insert( + FieldName::new("age"), + make_input_field( + "age", + "CreateUserInput", + TypeAnnotation::named("Int", /* nullable = */ true), + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("CreateUserInput"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Int"), int_scalar()); + + let validator = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies that an input field referencing an Object type +// (output-only) produces an InvalidInputFieldWithOutputType +// error. This is the critical fix over v0 which only checked +// as_object().is_some() -- v1 uses !is_input_type() to also +// reject Interface and Union types. +// https://spec.graphql.org/September2025/#sel-IAHhBXDDBFCAACEB4iG +// Written by Claude Code, reviewed by a human. +#[test] +fn input_field_with_object_type() { + let result_obj = GraphQLType::Object(Box::new( + ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields: IndexMap::new(), + interfaces: vec![], + name: TypeName::new("User"), + span: Span::dummy(), + }), + )); + + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("user"), + make_input_field( + "user", + "CreateInput", + TypeAnnotation::named("User", /* nullable = */ true), + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("CreateInput"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("User"), result_obj); + + let validator = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidInputFieldWithOutputType { + field_name, + invalid_type_name, + parent_type_name, + } if field_name == "user" + && invalid_type_name == "User" + && parent_type_name == "CreateInput" + )); +} + +// Verifies that an input field referencing an Interface type +// (output-only) produces an InvalidInputFieldWithOutputType +// error. This covers the bug in v0 where only Object types +// were rejected. +// https://spec.graphql.org/September2025/#sel-IAHhBXDDBFCAACEB4iG +// Written by Claude Code, reviewed by a human. +#[test] +fn input_field_with_interface_type() { + let iface = GraphQLType::Interface(Box::new( + InterfaceType(FieldedTypeData { + description: None, + directives: vec![], + fields: IndexMap::new(), + interfaces: vec![], + name: TypeName::new("Node"), + span: Span::dummy(), + }), + )); + + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("node"), + make_input_field( + "node", + "SearchInput", + TypeAnnotation::named("Node", /* nullable = */ true), + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("SearchInput"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("Node"), iface); + + let validator = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidInputFieldWithOutputType { + field_name, + invalid_type_name, + parent_type_name, + } if field_name == "node" + && invalid_type_name == "Node" + && parent_type_name == "SearchInput" + )); +} + +// Verifies that an input field referencing a Union type +// (output-only) produces an InvalidInputFieldWithOutputType +// error. This covers the bug in v0 where only Object types +// were rejected. +// https://spec.graphql.org/September2025/#sel-IAHhBXDDBFCAACEB4iG +// Written by Claude Code, reviewed by a human. +#[test] +fn input_field_with_union_type() { + let union_type = GraphQLType::Union(Box::new(UnionType { + description: None, + directives: vec![], + members: vec![], + name: TypeName::new("SearchResult"), + span: Span::dummy(), + })); + + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("result"), + make_input_field( + "result", + "FilterInput", + TypeAnnotation::named( + "SearchResult", + /* nullable = */ true, + ), + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("FilterInput"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("SearchResult"), union_type); + + let validator = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidInputFieldWithOutputType { + field_name, + invalid_type_name, + parent_type_name, + } if field_name == "result" + && invalid_type_name == "SearchResult" + && parent_type_name == "FilterInput" + )); +} + +// Verifies that an input field referencing an undefined type +// produces an UndefinedTypeName error. +// https://spec.graphql.org/September2025/#sec-Input-Objects +// Written by Claude Code, reviewed by a human. +#[test] +fn input_field_with_undefined_type() { + let mut fields = IndexMap::new(); + fields.insert( + FieldName::new("data"), + make_input_field( + "data", + "MyInput", + TypeAnnotation::named( + "NonExistent", + /* nullable = */ true, + ), + ), + ); + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields, + name: TypeName::new("MyInput"), + span: Span::dummy(), + }; + + let types_map = IndexMap::new(); + let validator = InputObjectTypeValidator::new( + &input_obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name, + } if undefined_type_name == "NonExistent" + )); +} + +// Verifies that a direct non-nullable circular reference between +// two input objects produces a CircularInputFieldChain error. +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn circular_non_nullable_input_field_chain() { + // A! -> B! -> A! (circular) + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("b"), + make_input_field( + "b", + "A", + TypeAnnotation::named("B", /* nullable = */ false), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut b_fields = IndexMap::new(); + b_fields.insert( + FieldName::new("a"), + make_input_field( + "a", + "B", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let b_type = InputObjectType { + description: None, + directives: vec![], + fields: b_fields, + name: TypeName::new("B"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + types_map.insert( + TypeName::new("B"), + GraphQLType::InputObject(Box::new(b_type)), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + + let circular_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::CircularInputFieldChain { .. } + )) + .collect(); + assert_eq!(circular_errors.len(), 1); + + // The path must contain the exact chain that forms the + // cycle: A.b -> B -> B.a -> A. + let TypeValidationErrorKind::CircularInputFieldChain { + circular_field_path, + } = circular_errors[0].kind() + else { + panic!( + "expected CircularInputFieldChain, got: {:?}", + circular_errors[0], + ); + }; + assert_eq!( + circular_field_path, + &vec![ + "A.b".to_string(), + "B".to_string(), + "B.a".to_string(), + "A".to_string(), + ], + "unexpected circular_field_path: {circular_field_path:?}", + ); + + // Also verify the Display output contains the expected + // chain segments joined by " -> ". + let msg = circular_errors[0].to_string(); + assert!( + msg.contains("`A.b`"), + "expected message to contain `A.b`: {msg}", + ); + assert!( + msg.contains("`B.a`"), + "expected message to contain `B.a`: {msg}", + ); + assert!( + msg.contains(" -> "), + "expected message to contain path separator ' -> ': {msg}", + ); +} + +// Verifies that a self-referencing input object (A -> A) with +// a non-nullable field produces a CircularInputFieldChain error. +// A single-node cycle is the simplest form of circular reference. +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn circular_self_reference_detected() { + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("self_ref"), + make_input_field( + "self_ref", + "A", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + + let circular_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::CircularInputFieldChain { .. } + )) + .collect(); + assert_eq!(circular_errors.len(), 1); + + // For a self-reference A.self_ref -> A, the path must be + // exactly [A.self_ref, A]. + let TypeValidationErrorKind::CircularInputFieldChain { + circular_field_path, + } = circular_errors[0].kind() + else { + panic!( + "expected CircularInputFieldChain, got: {:?}", + circular_errors[0], + ); + }; + assert_eq!( + circular_field_path, + &vec![ + "A.self_ref".to_string(), + "A".to_string(), + ], + "unexpected circular_field_path: {circular_field_path:?}", + ); + + // Also verify the Display output contains the self-reference + // segment. + let msg = circular_errors[0].to_string(); + assert!( + msg.contains("`A.self_ref`"), + "expected message to contain `A.self_ref`: {msg}", + ); + assert!( + msg.contains("`A.self_ref` -> `A`"), + "expected message to contain '`A.self_ref` -> `A`': {msg}", + ); +} + +// Verifies that a three-node circular chain (A -> B -> C -> A) +// with all non-nullable fields produces a CircularInputFieldChain +// error. Longer chains must also be detected. +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn circular_three_node_chain_detected() { + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("b"), + make_input_field( + "b", + "A", + TypeAnnotation::named("B", /* nullable = */ false), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut b_fields = IndexMap::new(); + b_fields.insert( + FieldName::new("c"), + make_input_field( + "c", + "B", + TypeAnnotation::named("C", /* nullable = */ false), + ), + ); + let b_type = InputObjectType { + description: None, + directives: vec![], + fields: b_fields, + name: TypeName::new("B"), + span: Span::dummy(), + }; + + let mut c_fields = IndexMap::new(); + c_fields.insert( + FieldName::new("a"), + make_input_field( + "a", + "C", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let c_type = InputObjectType { + description: None, + directives: vec![], + fields: c_fields, + name: TypeName::new("C"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + types_map.insert( + TypeName::new("B"), + GraphQLType::InputObject(Box::new(b_type)), + ); + types_map.insert( + TypeName::new("C"), + GraphQLType::InputObject(Box::new(c_type)), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + + let circular_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::CircularInputFieldChain { .. } + )) + .collect(); + assert_eq!(circular_errors.len(), 1); + + // For A.b -> B.c -> C.a -> A, the path must be exactly + // [A.b, B, B.c, C, C.a, A]. + let TypeValidationErrorKind::CircularInputFieldChain { + circular_field_path, + } = circular_errors[0].kind() + else { + panic!( + "expected CircularInputFieldChain, got: {:?}", + circular_errors[0], + ); + }; + assert_eq!( + circular_field_path, + &vec![ + "A.b".to_string(), + "B".to_string(), + "B.c".to_string(), + "C".to_string(), + "C.a".to_string(), + "A".to_string(), + ], + "unexpected circular_field_path: {circular_field_path:?}", + ); + + // Also verify the Display output contains each node in the + // chain. + let msg = circular_errors[0].to_string(); + assert!( + msg.contains("`A.b`"), + "expected message to contain `A.b`: {msg}", + ); + assert!( + msg.contains("`B.c`"), + "expected message to contain `B.c`: {msg}", + ); + assert!( + msg.contains("`C.a`"), + "expected message to contain `C.a`: {msg}", + ); +} + +// Verifies that a list type annotation breaks a circular input +// field chain, even when the list and inner type are both +// non-nullable (e.g. [A!]!). Per the September 2025 spec, ANY +// list wrapper breaks an input object cycle because list fields +// can always be satisfied with an empty list. +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn list_type_breaks_circular_chain() { + // input A { b: [A!]! } + // Non-nullable list of non-nullable A -- should NOT error + // because the list wrapper breaks the cycle. + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("b"), + make_input_field( + "b", + "A", + TypeAnnotation::list( + TypeAnnotation::named( + "A", + /* nullable = */ false, + ), + /* nullable = */ false, + ), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors (list breaks cycle), got: {errors:?}", + ); +} + +// Verifies that a nullable field breaks a circular reference +// chain and produces no CircularInputFieldChain error. +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn nullable_field_breaks_circular_chain() { + // A -> B (nullable) -> A (non-null) + // The nullable B field breaks the cycle. + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("b"), + make_input_field( + "b", + "A", + // nullable breaks cycle + TypeAnnotation::named("B", /* nullable = */ true), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut b_fields = IndexMap::new(); + b_fields.insert( + FieldName::new("a"), + make_input_field( + "a", + "B", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let b_type = InputObjectType { + description: None, + directives: vec![], + fields: b_fields, + name: TypeName::new("B"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + types_map.insert( + TypeName::new("B"), + GraphQLType::InputObject(Box::new(b_type)), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Regression test for a double-backtick wrapping bug in +// CircularInputFieldChain error messages. The validator +// previously wrapped path items in backticks (`A.b`), and +// then thiserror's #[error] attribute wrapped them again, +// producing double backticks like `` `A.b` ``. After the fix +// the validator emits raw path segments and thiserror adds a +// single layer of backtick formatting. +// +// This test triggers a real circular chain through the +// InputObjectTypeValidator and then inspects the Display +// output of the resulting error to confirm no double backticks +// appear. +// +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn circular_chain_error_message_no_double_backticks() { + // input A { b: B! } + // input B { a: A! } + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("b"), + make_input_field( + "b", + "A", + TypeAnnotation::named("B", /* nullable = */ false), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut b_fields = IndexMap::new(); + b_fields.insert( + FieldName::new("a"), + make_input_field( + "a", + "B", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let b_type = InputObjectType { + description: None, + directives: vec![], + fields: b_fields, + name: TypeName::new("B"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + types_map.insert( + TypeName::new("B"), + GraphQLType::InputObject(Box::new(b_type)), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + + let circular_errors: Vec<&TypeValidationError> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::CircularInputFieldChain { .. } + )) + .collect(); + assert_eq!(circular_errors.len(), 1); + + let msg = circular_errors[0].to_string(); + + // The message should contain single-backtick-wrapped path + // segments like `A.b`, NOT double-backtick-wrapped like + // `` `A.b` ``. + assert!( + !msg.contains("``"), + "error message contains double backticks, indicating \ + the double-wrapping bug has regressed: {msg}", + ); + + // Sanity check that the message still contains the expected + // path segments. + assert!( + msg.contains("`A.b`"), + "expected `A.b` in error message, got: {msg}", + ); + assert!( + msg.contains("`B.a`"), + "expected `B.a` in error message, got: {msg}", + ); +} + +// Regression test for a path-leaking bug in circular +// reference detection. The validator previously used +// extend_from_slice to push 2 items onto the path but only +// called pop() once, so stale entries from the first cycle +// leaked into the path for subsequent cycles. +// +// This test constructs: +// input A { b: B!, c: C! } +// input B { a: A! } +// input C { a: A! } +// +// Two independent cycles exist: +// A.b -> B.a -> A +// A.c -> C.a -> A +// +// The error for the "c" cycle must NOT contain "A.b" or "B" +// — those belong to the first cycle only. +// +// https://spec.graphql.org/September2025/#sec-Input-Objects.Type-Validation +// Written by Claude Code, reviewed by a human. +#[test] +fn circular_chain_no_path_leaking_between_cycles() { + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("b"), + make_input_field( + "b", + "A", + TypeAnnotation::named("B", /* nullable = */ false), + ), + ); + a_fields.insert( + FieldName::new("c"), + make_input_field( + "c", + "A", + TypeAnnotation::named("C", /* nullable = */ false), + ), + ); + let a_type = InputObjectType { + description: None, + directives: vec![], + fields: a_fields, + name: TypeName::new("A"), + span: Span::dummy(), + }; + + let mut b_fields = IndexMap::new(); + b_fields.insert( + FieldName::new("a"), + make_input_field( + "a", + "B", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let b_type = InputObjectType { + description: None, + directives: vec![], + fields: b_fields, + name: TypeName::new("B"), + span: Span::dummy(), + }; + + let mut c_fields = IndexMap::new(); + c_fields.insert( + FieldName::new("a"), + make_input_field( + "a", + "C", + TypeAnnotation::named("A", /* nullable = */ false), + ), + ); + let c_type = InputObjectType { + description: None, + directives: vec![], + fields: c_fields, + name: TypeName::new("C"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("A"), + GraphQLType::InputObject(Box::new(a_type.clone())), + ); + types_map.insert( + TypeName::new("B"), + GraphQLType::InputObject(Box::new(b_type)), + ); + types_map.insert( + TypeName::new("C"), + GraphQLType::InputObject(Box::new(c_type)), + ); + + let validator = InputObjectTypeValidator::new( + &a_type, + &types_map, + ); + let errors = validator.validate(); + + let circular_errors: Vec<&TypeValidationError> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::CircularInputFieldChain { .. } + )) + .collect(); + assert_eq!( + circular_errors.len(), 2, + "expected 2 circular chain errors (one per cycle), \ + got: {circular_errors:?}", + ); + + // Collect the Display output of each circular error + let messages: Vec = circular_errors + .iter() + .map(|e| e.to_string()) + .collect(); + + // Find the error for the b-cycle (contains "A.b") + let b_cycle_msg = messages.iter().find(|m| m.contains("A.b")); + assert!( + b_cycle_msg.is_some(), + "expected an error for the A.b -> B.a -> A cycle, \ + got messages: {messages:?}", + ); + let b_msg = b_cycle_msg.unwrap(); + assert!( + b_msg.contains("B.a"), + "b-cycle error should contain B.a: {b_msg}", + ); + // The b-cycle error must NOT mention "C" + assert!( + !b_msg.contains("C"), + "b-cycle error should not mention C (path leak): {b_msg}", + ); + + // Find the error for the c-cycle (contains "A.c") + let c_cycle_msg = messages.iter().find(|m| m.contains("A.c")); + assert!( + c_cycle_msg.is_some(), + "expected an error for the A.c -> C.a -> A cycle, \ + got messages: {messages:?}", + ); + let c_msg = c_cycle_msg.unwrap(); + assert!( + c_msg.contains("C.a"), + "c-cycle error should contain C.a: {c_msg}", + ); + // The c-cycle error must NOT mention "B" -- this is the + // key regression check. If path entries from the b-cycle + // leak into the c-cycle path, "B" would appear here. + assert!( + !c_msg.contains("B"), + "c-cycle error should not mention B (path leak from \ + first cycle): {c_msg}", + ); +} diff --git a/crates/libgraphql-core-v1/src/validators/tests/mod.rs b/crates/libgraphql-core-v1/src/validators/tests/mod.rs new file mode 100644 index 0000000..daa93d5 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/tests/mod.rs @@ -0,0 +1,4 @@ +mod directive_definition_validator_tests; +mod input_object_type_validator_tests; +mod object_or_interface_type_validator_tests; +mod union_type_validator_tests; diff --git a/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs new file mode 100644 index 0000000..9f677ba --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/tests/object_or_interface_type_validator_tests.rs @@ -0,0 +1,2131 @@ +use crate::located::Located; +use crate::names::FieldName; +use crate::names::TypeName; +use crate::schema::TypeValidationErrorKind; +use crate::span::Span; +use crate::span::SourceMapId; +use crate::types::FieldDefinition; +use crate::types::FieldedTypeData; +use crate::types::GraphQLType; +use crate::types::InterfaceType; +use crate::types::ObjectType; +use crate::types::ParameterDefinition; +use crate::types::ScalarKind; +use crate::types::ScalarType; +use crate::types::TypeAnnotation; +use crate::validators::ObjectOrInterfaceTypeValidator; +use indexmap::IndexMap; +use libgraphql_parser::ByteSpan; +use std::collections::HashSet; + +fn string_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::String, + name: TypeName::new("String"), + span: Span::builtin(), + })) +} + +fn int_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::Int, + name: TypeName::new("Int"), + span: Span::builtin(), + })) +} + +fn boolean_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::Boolean, + name: TypeName::new("Boolean"), + span: Span::builtin(), + })) +} + +fn make_field( + name: &str, + parent: &str, + type_annot: TypeAnnotation, +) -> FieldDefinition { + FieldDefinition { + description: None, + directives: vec![], + name: FieldName::new(name), + parameters: IndexMap::new(), + parent_type_name: TypeName::new(parent), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +fn make_field_with_params( + name: &str, + parent: &str, + type_annot: TypeAnnotation, + params: IndexMap, +) -> FieldDefinition { + FieldDefinition { + description: None, + directives: vec![], + name: FieldName::new(name), + parameters: params, + parent_type_name: TypeName::new(parent), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +fn make_param( + name: &str, + type_annot: TypeAnnotation, +) -> ParameterDefinition { + ParameterDefinition { + default_value: None, + description: None, + directives: vec![], + name: FieldName::new(name), + span: Span::dummy(), + type_annotation: type_annot, + } +} + +fn make_interface( + name: &str, + fields: IndexMap, + interfaces: Vec>, +) -> InterfaceType { + InterfaceType(FieldedTypeData { + description: None, + directives: vec![], + fields, + interfaces, + name: TypeName::new(name), + span: Span::dummy(), + }) +} + +fn make_object( + name: &str, + fields: IndexMap, + interfaces: Vec>, +) -> ObjectType { + ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields, + interfaces, + name: TypeName::new(name), + span: Span::dummy(), + }) +} + +fn located_type_name(name: &str) -> Located { + Located { + value: TypeName::new(name), + span: Span::dummy(), + } +} + +// Verifies that an object type correctly implementing an +// interface produces no validation errors. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn valid_object_implementing_interface() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("String"), + string_scalar(), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + types_map.insert( + TypeName::new("User"), + GraphQLType::Object(Box::new(obj.clone())), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies that implementing an undefined interface produces +// an ImplementsUndefinedInterface error whose span points at +// the interface reference (not the whole type definition). +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn implements_undefined_interface() { + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("id"), + make_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("NonExistent")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("String"), + string_scalar(), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::ImplementsUndefinedInterface { + type_name, + undefined_interface_name, + } if type_name == "User" + && undefined_interface_name == "NonExistent" + )); +} + +// Verifies that implementing a non-interface type (e.g. a +// scalar) produces an ImplementsNonInterfaceType error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn implements_non_interface_type() { + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("id"), + make_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("String")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("String"), + string_scalar(), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::ImplementsNonInterfaceType { + type_name, + non_interface_type_name, + } if type_name == "User" + && non_interface_type_name == "String" + )); +} + +// Verifies that a missing interface field produces a +// MissingInterfaceSpecifiedField error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn missing_interface_specified_field() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + iface_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + // Object only defines "name", missing "id" + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("String"), + string_scalar(), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::MissingInterfaceSpecifiedField { + field_name, + interface_name, + type_name, + } if field_name == "id" + && interface_name == "Node" + && type_name == "User" + )); +} + +// Verifies that a wrong parameter type on an implementing +// field produces an InvalidInterfaceSpecifiedFieldParameterType +// error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn invalid_interface_field_parameter_type() { + let mut iface_params = IndexMap::new(); + iface_params.insert( + FieldName::new("first"), + make_param( + "first", + TypeAnnotation::named("Int", /* nullable = */ true), + ), + ); + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("items"), + make_field_with_params( + "items", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + iface_params, + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + // Object defines "first" param as String instead of Int + let mut obj_params = IndexMap::new(); + obj_params.insert( + FieldName::new("first"), + make_param( + "first", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("items"), + make_field_with_params( + "items", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + obj_params, + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Int"), int_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidInterfaceSpecifiedFieldParameterType { + actual_type, + expected_type, + field_name, + interface_name, + parameter_name, + type_name, + } if actual_type == "String" + && expected_type == "Int" + && field_name == "items" + && interface_name == "Node" + && parameter_name == "first" + && type_name == "User" + )); + // The error should include a note pointing at the + // interface parameter definition. + assert!( + !errors[0].notes().is_empty(), + "expected notes on InvalidInterfaceSpecifiedFieldParameterType", + ); +} + +// Verifies that a missing parameter on an implementing field +// produces a MissingInterfaceSpecifiedFieldParameter error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn missing_interface_specified_field_parameter() { + let mut iface_params = IndexMap::new(); + iface_params.insert( + FieldName::new("first"), + make_param( + "first", + TypeAnnotation::named("Int", /* nullable = */ true), + ), + ); + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("items"), + make_field_with_params( + "items", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + iface_params, + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + // Object defines "items" field but without the "first" param + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("items"), + make_field( + "items", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Int"), int_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::MissingInterfaceSpecifiedFieldParameter { + field_name, + interface_name, + missing_parameter_name, + type_name, + } if field_name == "items" + && interface_name == "Node" + && missing_parameter_name == "first" + && type_name == "User" + )); +} + +// Verifies that a required additional parameter (not in the +// interface) on the implementing field produces an +// InvalidRequiredAdditionalParameterOnInterfaceSpecifiedField +// error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn required_additional_parameter_on_interface_field() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + // Object adds a required (non-null, no default) extra param + let mut obj_params = IndexMap::new(); + obj_params.insert( + FieldName::new("extra"), + make_param( + "extra", + TypeAnnotation::named("Boolean", /* nullable = */ false), + ), + ); + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_field_with_params( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + obj_params, + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Boolean"), boolean_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidRequiredAdditionalParameterOnInterfaceSpecifiedField { + field_name, + interface_name, + parameter_name, + type_name, + } if field_name == "name" + && interface_name == "Node" + && parameter_name == "extra" + && type_name == "User" + )); + // The error should include a note pointing at the + // interface field definition. + assert!( + !errors[0].notes().is_empty(), + "expected notes on \ + InvalidRequiredAdditionalParameterOnInterfaceSpecifiedField", + ); +} + +// Verifies that a non-covariant return type on an implementing +// field produces an InvalidInterfaceSpecifiedFieldType error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn invalid_interface_specified_field_type() { + let mut iface_fields = IndexMap::new(); + iface_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let iface = make_interface("Node", iface_fields, vec![]); + + // Object returns nullable String where interface requires + // non-null String (widening nullability is not covariant) + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidInterfaceSpecifiedFieldType { + actual_type, + expected_type, + field_name, + interface_name, + type_name, + } if actual_type == "String" + && expected_type == "String!" + && field_name == "name" + && interface_name == "Node" + && type_name == "User" + )); + // The error should include a note pointing at the + // interface field's return type declaration. + assert!( + !errors[0].notes().is_empty(), + "expected notes on InvalidInterfaceSpecifiedFieldType", + ); +} + +// Verifies that a missing transitive interface implementation +// produces a MissingRecursiveInterfaceImplementation error. +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn missing_recursive_interface_implementation() { + // Base interface + let mut base_fields = IndexMap::new(); + base_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Base", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let base_iface = make_interface("Base", base_fields, vec![]); + + // Middle interface implements Base + let mut mid_fields = IndexMap::new(); + mid_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Middle", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + mid_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Middle", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let mid_iface = make_interface( + "Middle", + mid_fields, + vec![located_type_name("Base")], + ); + + // Object implements Middle but NOT Base + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("id"), + make_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + obj_fields.insert( + FieldName::new("name"), + make_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ true), + ), + ); + let obj = make_object( + "User", + obj_fields, + vec![located_type_name("Middle")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Base"), + GraphQLType::Interface(Box::new(base_iface)), + ); + types_map.insert( + TypeName::new("Middle"), + GraphQLType::Interface(Box::new(mid_iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + + let missing_recursive_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { .. } + )) + .collect(); + assert_eq!(missing_recursive_errors.len(), 1); + assert!(matches!( + missing_recursive_errors[0].kind(), + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { + inheritance_path, + missing_recursive_interface_name, + type_name, + } if missing_recursive_interface_name == "Base" + && type_name == "User" + && !inheritance_path.is_empty() + && inheritance_path.contains(&"Middle".to_string()) + )); + + // The Display output must not contain a dangling + // "implements ," (nothing between "implements" and the + // comma), which would indicate the inheritance_path + // vector had been left empty at the point of error. + let msg = missing_recursive_errors[0].to_string(); + assert!( + !msg.contains("implements ,"), + "error message should not contain dangling 'implements ,' \ + (indicates empty inheritance_path): {msg}", + ); + assert!( + msg.contains("`Middle`"), + "error message should mention the transitive interface \ + `Middle`: {msg}", + ); +} + +// Verifies that a field referencing an undefined return type +// produces an UndefinedTypeName error. +// https://spec.graphql.org/September2025/#sec-Objects +// Written by Claude Code, reviewed by a human. +#[test] +fn object_field_with_undefined_type() { + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("data"), + make_field( + "data", + "Query", + TypeAnnotation::named( + "NonExistent", + /* nullable = */ true, + ), + ), + ); + let obj = make_object("Query", obj_fields, vec![]); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name, + } if undefined_type_name == "NonExistent" + )); +} + +// Verifies that a field using an input-only type (InputObject) +// as a return type produces an InvalidOutputFieldWithInputType +// error. +// https://spec.graphql.org/September2025/#sel-JAHZhCFDBFABLBgB_pM +// Written by Claude Code, reviewed by a human. +#[test] +fn object_field_with_input_only_type() { + use crate::types::InputObjectType; + + let input_obj = InputObjectType { + description: None, + directives: vec![], + fields: IndexMap::new(), + name: TypeName::new("CreateUserInput"), + span: Span::dummy(), + }; + + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("data"), + make_field( + "data", + "Query", + TypeAnnotation::named( + "CreateUserInput", + /* nullable = */ true, + ), + ), + ); + let obj = make_object("Query", obj_fields, vec![]); + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("CreateUserInput"), + GraphQLType::InputObject(Box::new(input_obj)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidOutputFieldWithInputType { + field_name, + input_type_name, + parent_type_name, + } if field_name == "data" + && input_type_name == "CreateUserInput" + && parent_type_name == "Query" + )); +} + +// Verifies that a field parameter using an output-only type +// (Object) produces an InvalidParameterWithOutputOnlyType +// error. +// https://spec.graphql.org/September2025/#sel-KAHZhCFDBHBDCAACEB6yD +// Written by Claude Code, reviewed by a human. +#[test] +fn object_field_param_with_output_only_type() { + let result_obj = ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields: IndexMap::new(), + interfaces: vec![], + name: TypeName::new("Result"), + span: Span::dummy(), + }); + + let mut obj_params = IndexMap::new(); + obj_params.insert( + FieldName::new("input"), + make_param( + "input", + TypeAnnotation::named("Result", /* nullable = */ true), + ), + ); + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("doSomething"), + make_field_with_params( + "doSomething", + "Query", + TypeAnnotation::named("String", /* nullable = */ true), + obj_params, + ), + ); + let obj = make_object("Query", obj_fields, vec![]); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Result"), + GraphQLType::Object(Box::new(result_obj)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidParameterWithOutputOnlyType { + field_name, + invalid_type_name, + parameter_name, + type_name, + } if field_name == "doSomething" + && invalid_type_name == "Result" + && parameter_name == "input" + && type_name == "Query" + )); +} + +// Verifies that an interface type implementing another interface +// is validated correctly. Per the September 2025 spec, interfaces +// can implement other interfaces, and the same IsValidImplementation +// rules apply. This test validates that the validator works with +// InterfaceType as the type under validation (not just ObjectType). +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn interface_implementing_interface_validates() { + // interface Node { id: ID! } + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Node", + TypeAnnotation::named("ID", /* nullable = */ false), + ), + ); + let node_iface = make_interface("Node", node_fields, vec![]); + + // interface Resource implements Node { id: ID!, url: String! } + let mut resource_fields = IndexMap::new(); + resource_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Resource", + TypeAnnotation::named("ID", /* nullable = */ false), + ), + ); + resource_fields.insert( + FieldName::new("url"), + make_field( + "url", + "Resource", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let resource_iface = make_interface( + "Resource", + resource_fields, + vec![located_type_name("Node")], + ); + + let id_scalar = GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::ID, + name: TypeName::new("ID"), + span: Span::builtin(), + })); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("ID"), id_scalar); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + types_map.insert( + TypeName::new("Resource"), + GraphQLType::Interface(Box::new(resource_iface.clone())), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &resource_iface, + &types_map, + ); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Regression test for a bug where the recursive child +// validator used the current interface's implemented interfaces +// instead of the implementing type's interfaces. +// +// Setup: +// interface B { id: ID! } +// interface A implements B { id: ID! } +// type C implements A & B { id: ID! } +// +// When validating C's implementation of A, the validator +// recursively checks that C also implements everything A +// implements (i.e. B). The recursive check must look at C's +// declared interfaces (which includes B), NOT A's interfaces. +// Before the fix, the child validator was initialized with A's +// interface set, so it would produce a false +// MissingRecursiveInterfaceImplementation error for C even +// though C explicitly declares `implements A & B`. +// +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn recursive_validation_uses_implementing_types_interfaces() { + let id_scalar = GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::ID, + name: TypeName::new("ID"), + span: Span::builtin(), + })); + + // interface B { id: ID! } + let mut b_fields = IndexMap::new(); + b_fields.insert( + FieldName::new("id"), + make_field( + "id", + "B", + TypeAnnotation::named("ID", /* nullable = */ false), + ), + ); + let b_iface = make_interface("B", b_fields, vec![]); + + // interface A implements B { id: ID! } + let mut a_fields = IndexMap::new(); + a_fields.insert( + FieldName::new("id"), + make_field( + "id", + "A", + TypeAnnotation::named("ID", /* nullable = */ false), + ), + ); + let a_iface = make_interface( + "A", + a_fields, + vec![located_type_name("B")], + ); + + // type C implements A & B { id: ID! } + let mut c_fields = IndexMap::new(); + c_fields.insert( + FieldName::new("id"), + make_field( + "id", + "C", + TypeAnnotation::named("ID", /* nullable = */ false), + ), + ); + let c_obj = make_object( + "C", + c_fields, + vec![ + located_type_name("A"), + located_type_name("B"), + ], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("ID"), id_scalar); + types_map.insert( + TypeName::new("A"), + GraphQLType::Interface(Box::new(a_iface)), + ); + types_map.insert( + TypeName::new("B"), + GraphQLType::Interface(Box::new(b_iface)), + ); + types_map.insert( + TypeName::new("C"), + GraphQLType::Object(Box::new(c_obj.clone())), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &c_obj, + &types_map, + ); + let errors = validator.validate(); + + // C correctly declares both A and B, so there should be + // no errors. Before the fix, the child validator would + // have used A's interface set (just {B}) when checking C's + // recursive implementations of A's parents, which would + // produce a spurious MissingRecursiveInterfaceImplementation + // error because A's interface set was being compared against + // itself rather than C's. + assert!( + errors.is_empty(), + "expected no errors (C correctly implements A & B), \ + got: {errors:?}", + ); +} + +// Regression test: verifies the Display output of +// MissingRecursiveInterfaceImplementation contains the current +// interface's name and does NOT contain a dangling +// "implements ," (with nothing between "implements" and the +// comma). Prior to the fix, the validator passed +// `self.inheritance_path` directly to the error without +// including the current `iface_name`, which meant a top-level +// call (with an empty path) produced an error message like +// "`User` implements , therefore ...". +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn missing_recursive_interface_display_includes_path() { + // Node interface (the transitively-required ancestor). + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let node_iface = make_interface("Node", node_fields, vec![]); + + // Resource interface implements Node. + let mut resource_fields = IndexMap::new(); + resource_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Resource", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let resource_iface = make_interface( + "Resource", + resource_fields, + vec![located_type_name("Node")], + ); + + // User object implements Resource but NOT Node. + let mut user_fields = IndexMap::new(); + user_fields.insert( + FieldName::new("id"), + make_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let user_obj = make_object( + "User", + user_fields, + vec![located_type_name("Resource")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + types_map.insert( + TypeName::new("Resource"), + GraphQLType::Interface(Box::new(resource_iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &user_obj, + &types_map, + ); + let errors = validator.validate(); + + let missing_recursive_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { .. } + )) + .collect(); + assert_eq!( + missing_recursive_errors.len(), 1, + "expected exactly one MissingRecursiveInterfaceImplementation \ + error, got: {errors:?}", + ); + + let msg = missing_recursive_errors[0].to_string(); + + // The key regression check: no dangling "implements ," + // with nothing between "implements" and the comma. + assert!( + !msg.contains("implements ,"), + "error message should not contain dangling 'implements ,' \ + (indicates empty inheritance_path): {msg}", + ); + + // The error message should clearly reference the interface + // `User` directly implements (Resource), because that is + // the immediate cause of the transitive requirement. + assert!( + msg.contains("`Resource`"), + "error message should contain the directly-implemented \ + interface `Resource`: {msg}", + ); + assert!( + msg.contains("`Node`"), + "error message should contain the missing transitive \ + interface `Node`: {msg}", + ); + assert!( + msg.contains("`User`"), + "error message should reference the implementing type \ + `User`: {msg}", + ); +} + +// Regression test for a bug where the recursive interface +// walker only descended one level deep because the child +// validator re-iterated the implementing type's own +// `interfaces()` list (which ran out of new names after the +// first level) rather than walking each interface's own +// `interfaces()` chain. The visible symptom of the bug was +// that transitive interface requirements more than one level +// deep were silently ignored. +// +// Setup: +// interface Root { root: String! } +// interface Entity implements Root { +// root: String! +// entity: String! +// } +// interface Node implements Entity & Root { +// root: String! +// entity: String! +// node: String! +// } +// type User implements Node { ... } +// +// `User` only directly declares `Node`, but per IsValidImplementation +// it must transitively declare every interface `Node` implements +// (including interfaces that `Node`'s own parents implement). So +// validating `User` must produce +// MissingRecursiveInterfaceImplementation errors for BOTH `Entity` +// (one level up from `Node`) AND `Root` (two levels up from `Node`, +// via `Entity`). +// +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn missing_two_level_deep_transitive_interface() { + // interface Root { root: String! } + let mut root_fields = IndexMap::new(); + root_fields.insert( + FieldName::new("root"), + make_field( + "root", + "Root", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let root_iface = make_interface("Root", root_fields, vec![]); + + // interface Entity implements Root { + // root: String! + // entity: String! + // } + let mut entity_fields = IndexMap::new(); + entity_fields.insert( + FieldName::new("root"), + make_field( + "root", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + entity_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let entity_iface = make_interface( + "Entity", + entity_fields, + vec![located_type_name("Root")], + ); + + // interface Node implements Entity & Root { + // root: String! + // entity: String! + // node: String! + // } + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("root"), + make_field( + "root", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + node_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + node_fields.insert( + FieldName::new("node"), + make_field( + "node", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let node_iface = make_interface( + "Node", + node_fields, + vec![ + located_type_name("Entity"), + located_type_name("Root"), + ], + ); + + // type User implements Node { ... } -- intentionally does + // NOT declare Entity or Root, which is the spec violation + // under test. + let mut user_fields = IndexMap::new(); + user_fields.insert( + FieldName::new("root"), + make_field( + "root", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + user_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + user_fields.insert( + FieldName::new("node"), + make_field( + "node", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let user_obj = make_object( + "User", + user_fields, + vec![located_type_name("Node")], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Root"), + GraphQLType::Interface(Box::new(root_iface)), + ); + types_map.insert( + TypeName::new("Entity"), + GraphQLType::Interface(Box::new(entity_iface)), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &user_obj, + &types_map, + ); + let errors = validator.validate(); + + let missing_recursive_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { .. } + )) + .collect(); + + // Collect the set of missing interface names for assertions. + let missing_names: HashSet = missing_recursive_errors + .iter() + .filter_map(|e| match e.kind() { + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { + missing_recursive_interface_name, + .. + } => Some(missing_recursive_interface_name.clone()), + _ => None, + }) + .collect(); + + assert!( + missing_names.contains("Entity"), + "expected a MissingRecursiveInterfaceImplementation \ + error for `Entity`, got: {errors:?}", + ); + assert!( + missing_names.contains("Root"), + "expected a MissingRecursiveInterfaceImplementation \ + error for `Root` (two levels deep, transitively required \ + via Node -> Entity), got: {errors:?}", + ); + + // The Entity error should cite a path that starts at `Node` + // (the interface `User` directly declares). The Root error + // should cite a path that walks `Node -> Entity` (since + // `Root` is required via Entity). + for e in &missing_recursive_errors { + let TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { + inheritance_path, + missing_recursive_interface_name, + type_name, + } = e.kind() else { + continue; + }; + assert_eq!(type_name, "User"); + assert!( + !inheritance_path.is_empty(), + "inheritance_path must not be empty for {missing_recursive_interface_name}: {e:?}", + ); + // Every error's inheritance path must start with `Node` + // (the directly-declared interface on `User`). + assert_eq!( + inheritance_path[0], "Node", + "inheritance_path should start at the directly-declared \ + interface `Node`, got: {inheritance_path:?}", + ); + if missing_recursive_interface_name == "Root" { + // The Root error may be reported via either the + // Node -> Entity chain or directly via Node (since + // Node itself also declares `implements Root`). + // Either way, the first entry must be Node. + assert!( + inheritance_path.last() + .map(|s| s == "Node" || s == "Entity") + .unwrap_or(false), + "last entry in inheritance_path for `Root` should \ + be either `Node` or `Entity`: {inheritance_path:?}", + ); + } + } +} + +// Regression companion to +// `missing_two_level_deep_transitive_interface`: same 3-level +// interface hierarchy, but the implementing type DOES declare +// every transitively-required interface. Validates the +// positive case so that the recursive walker cannot regress to +// a mode where it spuriously emits +// MissingRecursiveInterfaceImplementation errors on correctly +// declared types. +// +// Setup: +// interface Root { root: String! } +// interface Entity implements Root { ... } +// interface Node implements Entity & Root { ... } +// type User implements Node & Entity & Root { ... } +// +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn valid_three_level_deep_transitive_interface_declaration() { + // interface Root { root: String! } + let mut root_fields = IndexMap::new(); + root_fields.insert( + FieldName::new("root"), + make_field( + "root", + "Root", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let root_iface = make_interface("Root", root_fields, vec![]); + + // interface Entity implements Root { ... } + let mut entity_fields = IndexMap::new(); + entity_fields.insert( + FieldName::new("root"), + make_field( + "root", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + entity_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let entity_iface = make_interface( + "Entity", + entity_fields, + vec![located_type_name("Root")], + ); + + // interface Node implements Entity & Root { ... } + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("root"), + make_field( + "root", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + node_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + node_fields.insert( + FieldName::new("node"), + make_field( + "node", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let node_iface = make_interface( + "Node", + node_fields, + vec![ + located_type_name("Entity"), + located_type_name("Root"), + ], + ); + + // type User implements Node & Entity & Root { ... } + let mut user_fields = IndexMap::new(); + user_fields.insert( + FieldName::new("root"), + make_field( + "root", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + user_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + user_fields.insert( + FieldName::new("node"), + make_field( + "node", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let user_obj = make_object( + "User", + user_fields, + vec![ + located_type_name("Node"), + located_type_name("Entity"), + located_type_name("Root"), + ], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Root"), + GraphQLType::Interface(Box::new(root_iface)), + ); + types_map.insert( + TypeName::new("Entity"), + GraphQLType::Interface(Box::new(entity_iface)), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &user_obj, + &types_map, + ); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors (User correctly declares \ + Node & Entity & Root), got: {errors:?}", + ); +} + +// Regression test for the shared-state bug where +// `check_interface_chain` inserted inherited interface names +// into the same set used by the main loop's +// `verified_interface_impls`, preventing directly-declared +// interfaces from being field-validated. +// +// Setup: +// interface Entity { entity: String! } +// interface Node implements Entity { entity: String!, node: String! } +// type User implements Node & Entity { node: String! } +// +// User declares both Node and Entity. Node implements Entity, +// so when validating Node's transitive chain the old code +// would insert "Entity" into the shared set. Then when the +// main loop reached Entity (a direct declaration), it would +// skip Entity's field-contract validation entirely. This meant +// User's missing `entity` field would go unreported. +// +// With the new two-phase design, transitive completeness +// (phase 1) and field contract validation (phase 2) use +// independent state, so Entity's field contract IS validated. +// +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn shared_state_bug_entity_field_contract_validated() { + // interface Entity { entity: String! } + let mut entity_fields = IndexMap::new(); + entity_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let entity_iface = make_interface( + "Entity", + entity_fields, + vec![], + ); + + // interface Node implements Entity { + // entity: String! + // node: String! + // } + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("entity"), + make_field( + "entity", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + node_fields.insert( + FieldName::new("node"), + make_field( + "node", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let node_iface = make_interface( + "Node", + node_fields, + vec![located_type_name("Entity")], + ); + + // type User implements Node & Entity { node: String! } + // INTENTIONALLY missing "entity" field -- the bug would + // allow this to pass silently. + let mut user_fields = IndexMap::new(); + user_fields.insert( + FieldName::new("node"), + make_field( + "node", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let user_obj = make_object( + "User", + user_fields, + vec![ + located_type_name("Node"), + located_type_name("Entity"), + ], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Entity"), + GraphQLType::Interface(Box::new(entity_iface)), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + types_map.insert( + TypeName::new("User"), + GraphQLType::Object(Box::new(user_obj.clone())), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &user_obj, + &types_map, + ); + let errors = validator.validate(); + + // We should get MissingInterfaceSpecifiedField for the + // missing "entity" field. Both Node and Entity require it, + // so there should be exactly ONE error for it from the + // Node field contract (Entity's field contract is deduped + // because Node already checked it... wait, no: Node and + // Entity are separate interfaces with separate field + // contracts). Actually: Node requires "entity" and Entity + // requires "entity". The field_validated_interfaces dedup + // prevents checking Entity's fields if Entity was already + // checked... but Entity is checked as a SEPARATE direct + // declaration, so it WILL be checked. However, since User + // is missing "entity" from BOTH Node and Entity, we get + // two errors (one per interface). + // + // The critical assertion is that we get AT LEAST one + // MissingInterfaceSpecifiedField for "entity" -- the old + // bug would produce zero. + let missing_entity_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::MissingInterfaceSpecifiedField { + field_name, + .. + } if field_name == "entity" + )) + .collect(); + + assert!( + !missing_entity_errors.is_empty(), + "expected at least one MissingInterfaceSpecifiedField \ + error for `entity` (regression: shared-state bug would \ + have suppressed Entity's field validation), got: {errors:?}", + ); +} + +// Verifies that each error appears exactly once -- no +// duplicate errors from multiple vantage points. +// +// Setup (same as shared_state_bug test): +// interface Entity { entity: String! } +// interface Node implements Entity { entity: String!, node: String! } +// type User implements Node & Entity { node: String! } +// +// User is missing "entity". Both Node and Entity specify it. +// The dedup set in field-contract validation means Node's +// field contract is checked first (it appears first in the +// implements list), and then Entity's field contract is checked +// separately. Since "entity" is missing from User, we get one +// error from each interface -- but these are legitimately +// different errors (different interface_name), not duplicates. +// +// However, if User listed Entity TWICE somehow, or if the +// transitive walk produced field-contract errors, we'd get +// true duplicates. This test verifies no exact duplicates +// exist. +// +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn no_duplicate_errors() { + // interface Entity { name: String! } + let mut entity_fields = IndexMap::new(); + entity_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let entity_iface = make_interface( + "Entity", + entity_fields, + vec![], + ); + + // interface Node implements Entity { name: String! } + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let node_iface = make_interface( + "Node", + node_fields, + vec![located_type_name("Entity")], + ); + + // type User implements Node & Entity { id: String! } + // Missing "name" from both interfaces. + let mut user_fields = IndexMap::new(); + user_fields.insert( + FieldName::new("id"), + make_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let user_obj = make_object( + "User", + user_fields, + vec![ + located_type_name("Node"), + located_type_name("Entity"), + ], + ); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Entity"), + GraphQLType::Interface(Box::new(entity_iface)), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &user_obj, + &types_map, + ); + let errors = validator.validate(); + + // Check for exact duplicates: stringify each error and + // verify no two are identical. + let error_strings: Vec = + errors.iter().map(|e| format!("{e:?}")).collect(); + let unique_errors: HashSet<&String> = + error_strings.iter().collect(); + + assert_eq!( + error_strings.len(), + unique_errors.len(), + "found duplicate errors: {error_strings:#?}", + ); + + // Additionally verify we got the expected error types: + // - MissingInterfaceSpecifiedField for "name" from Node + // - MissingInterfaceSpecifiedField for "name" from Entity + let missing_field_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::MissingInterfaceSpecifiedField { .. } + )) + .collect(); + + // Node requires "name" (checked), Entity requires "name" + // (checked separately because it's a different interface). + assert_eq!( + missing_field_errors.len(), 2, + "expected exactly 2 MissingInterfaceSpecifiedField \ + errors (one from Node, one from Entity), got: \ + {missing_field_errors:?}", + ); +} + +// Verifies that MissingRecursiveInterfaceImplementation errors +// use the correct span -- specifically, the span of the +// `implements ` clause that triggers the transitive +// requirement, NOT the span of the whole type definition. +// +// Uses non-builtin spans so we can distinguish them. +// +// Setup: +// interface Entity { id: String! } +// interface Node implements Entity { id: String!, name: String! } +// type User implements Node { id: String!, name: String! } +// +// User is missing `Entity` (transitively required via Node). +// The error's span should point at the `implements Node` +// clause on User. +// +// https://spec.graphql.org/September2025/#IsValidImplementation() +// Written by Claude Code, reviewed by a human. +#[test] +fn missing_recursive_error_span_points_at_implements_clause() { + let user_source_map = SourceMapId(1); + let user_type_span = Span::new( + ByteSpan::new(0, 100), + user_source_map, + ); + let user_implements_node_span = Span::new( + ByteSpan::new(30, 34), + user_source_map, + ); + + // interface Entity { id: String! } + let mut entity_fields = IndexMap::new(); + entity_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Entity", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let entity_iface = make_interface( + "Entity", + entity_fields, + vec![], + ); + + // interface Node implements Entity { id: String!, name: String! } + let mut node_fields = IndexMap::new(); + node_fields.insert( + FieldName::new("id"), + make_field( + "id", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + node_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Node", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let node_iface = make_interface( + "Node", + node_fields, + vec![located_type_name("Entity")], + ); + + // type User implements Node { id: String!, name: String! } + // Uses explicit spans to verify the error points at the + // implements clause. + let mut user_fields = IndexMap::new(); + user_fields.insert( + FieldName::new("id"), + make_field( + "id", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + user_fields.insert( + FieldName::new("name"), + make_field( + "name", + "User", + TypeAnnotation::named("String", /* nullable = */ false), + ), + ); + let user_obj = ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields: user_fields, + interfaces: vec![Located { + value: TypeName::new("Node"), + span: user_implements_node_span, + }], + name: TypeName::new("User"), + span: user_type_span, + }); + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert( + TypeName::new("Entity"), + GraphQLType::Interface(Box::new(entity_iface)), + ); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(node_iface)), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &user_obj, + &types_map, + ); + let errors = validator.validate(); + + let missing_recursive_errors: Vec<_> = errors + .iter() + .filter(|e| matches!( + e.kind(), + TypeValidationErrorKind::MissingRecursiveInterfaceImplementation { .. } + )) + .collect(); + + assert_eq!( + missing_recursive_errors.len(), 1, + "expected exactly one MissingRecursiveInterfaceImplementation \ + error, got: {errors:?}", + ); + + let error = &missing_recursive_errors[0]; + + // The error span must match the `implements Node` clause + // span, NOT the whole type span. + assert_eq!( + error.span(), + user_implements_node_span, + "error span should point at the `implements Node` \ + clause (byte 30..34), not the whole type definition \ + (byte 0..100). Got: {:?}", + error.span(), + ); + + // Verify the span is NOT the type definition span. + assert_ne!( + error.span(), + user_type_span, + "error span must NOT be the whole type definition span", + ); +} + +// Verifies that an UndefinedTypeName error for a field type +// that is similar to a known type includes a "did you mean" +// help note suggesting the correct type name. +// +// This tests the edit-distance suggestion feature: when a +// type reference is close to a defined type (e.g. "Strng" +// vs "String"), the error should include a help note. +// +// https://spec.graphql.org/September2025/#sec-Types +// Written by Claude Code, reviewed by a human. +#[test] +fn undefined_field_type_with_did_you_mean_suggestion() { + use crate::error_note::ErrorNoteKind; + + let mut obj_fields = IndexMap::new(); + obj_fields.insert( + FieldName::new("name"), + make_field( + "name", + "Query", + TypeAnnotation::named( + "Strng", + /* nullable = */ true, + ), + ), + ); + let obj = make_object("Query", obj_fields, vec![]); + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("String"), + string_scalar(), + ); + + let validator = ObjectOrInterfaceTypeValidator::new( + &obj, + &types_map, + ); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name, + } if undefined_type_name == "Strng" + )); + + // Verify the error has a "did you mean" help note. + let help_notes: Vec<_> = errors[0] + .notes() + .iter() + .filter(|n| n.kind == ErrorNoteKind::Help) + .collect(); + assert_eq!( + help_notes.len(), 1, + "expected exactly one help note, got: {:?}", + errors[0].notes(), + ); + assert_eq!( + help_notes[0].message, + "did you mean `String`?", + ); + + // Verify a spec note is also present. + let spec_notes: Vec<_> = errors[0] + .notes() + .iter() + .filter(|n| n.kind == ErrorNoteKind::Spec) + .collect(); + assert_eq!( + spec_notes.len(), 1, + "expected exactly one spec note, got: {:?}", + errors[0].notes(), + ); +} diff --git a/crates/libgraphql-core-v1/src/validators/tests/union_type_validator_tests.rs b/crates/libgraphql-core-v1/src/validators/tests/union_type_validator_tests.rs new file mode 100644 index 0000000..543c9b5 --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/tests/union_type_validator_tests.rs @@ -0,0 +1,186 @@ +use crate::located::Located; +use crate::names::FieldName; +use crate::names::TypeName; +use crate::schema::TypeValidationErrorKind; +use crate::span::Span; +use crate::types::FieldDefinition; +use crate::types::FieldedTypeData; +use crate::types::GraphQLType; +use crate::types::InterfaceType; +use crate::types::ObjectType; +use crate::types::ScalarKind; +use crate::types::ScalarType; +use crate::types::TypeAnnotation; +use crate::types::UnionType; +use crate::validators::UnionTypeValidator; +use indexmap::IndexMap; + +fn string_scalar() -> GraphQLType { + GraphQLType::Scalar(Box::new(ScalarType { + description: None, + directives: vec![], + kind: ScalarKind::String, + name: TypeName::new("String"), + span: Span::builtin(), + })) +} + +fn make_object_type(name: &str) -> GraphQLType { + let mut fields = IndexMap::new(); + fields.insert(FieldName::new("id"), FieldDefinition { + description: None, + directives: vec![], + name: FieldName::new("id"), + parameters: IndexMap::new(), + parent_type_name: TypeName::new(name), + span: Span::dummy(), + type_annotation: TypeAnnotation::named( + "String", + /* nullable = */ false, + ), + }); + GraphQLType::Object(Box::new(ObjectType(FieldedTypeData { + description: None, + directives: vec![], + fields, + interfaces: vec![], + name: TypeName::new(name), + span: Span::dummy(), + }))) +} + +fn located_type_name(name: &str) -> Located { + Located { + value: TypeName::new(name), + span: Span::dummy(), + } +} + +// Verifies that a valid union with all object members produces +// no errors. +// https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib +// Written by Claude Code, reviewed by a human. +#[test] +fn valid_union_type() { + let union_type = UnionType { + description: None, + directives: vec![], + members: vec![ + located_type_name("Dog"), + located_type_name("Cat"), + ], + name: TypeName::new("Pet"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + types_map.insert(TypeName::new("Dog"), make_object_type("Dog")); + types_map.insert(TypeName::new("Cat"), make_object_type("Cat")); + + let validator = UnionTypeValidator::new(&union_type, &types_map); + let errors = validator.validate(); + assert!( + errors.is_empty(), + "expected no errors, got: {errors:?}", + ); +} + +// Verifies that a union referencing an undefined member type +// produces an UndefinedTypeName error. +// https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib +// Written by Claude Code, reviewed by a human. +#[test] +fn union_with_undefined_member() { + let union_type = UnionType { + description: None, + directives: vec![], + members: vec![located_type_name("Ghost")], + name: TypeName::new("Pet"), + span: Span::dummy(), + }; + + let types_map = IndexMap::new(); + let validator = UnionTypeValidator::new(&union_type, &types_map); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name, + } if undefined_type_name == "Ghost" + )); +} + +// Verifies that a union member that is not an object type +// (e.g. an interface) produces an InvalidUnionMemberTypeKind +// error. +// https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib +// Written by Claude Code, reviewed by a human. +#[test] +fn union_with_non_object_member() { + let iface = InterfaceType(FieldedTypeData { + description: None, + directives: vec![], + fields: IndexMap::new(), + interfaces: vec![], + name: TypeName::new("Node"), + span: Span::dummy(), + }); + + let union_type = UnionType { + description: None, + directives: vec![], + members: vec![located_type_name("Node")], + name: TypeName::new("Result"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert( + TypeName::new("Node"), + GraphQLType::Interface(Box::new(iface)), + ); + + let validator = UnionTypeValidator::new(&union_type, &types_map); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidUnionMemberTypeKind { + member_name, + union_type_name, + } if member_name == "Node" + && union_type_name == "Result" + )); +} + +// Verifies that a union with a scalar member produces an +// InvalidUnionMemberTypeKind error. +// https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib +// Written by Claude Code, reviewed by a human. +#[test] +fn union_with_scalar_member() { + let union_type = UnionType { + description: None, + directives: vec![], + members: vec![located_type_name("String")], + name: TypeName::new("SearchResult"), + span: Span::dummy(), + }; + + let mut types_map = IndexMap::new(); + types_map.insert(TypeName::new("String"), string_scalar()); + + let validator = UnionTypeValidator::new(&union_type, &types_map); + let errors = validator.validate(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0].kind(), + TypeValidationErrorKind::InvalidUnionMemberTypeKind { + member_name, + union_type_name, + } if member_name == "String" + && union_type_name == "SearchResult" + )); +} diff --git a/crates/libgraphql-core-v1/src/validators/union_type_validator.rs b/crates/libgraphql-core-v1/src/validators/union_type_validator.rs new file mode 100644 index 0000000..9c695bd --- /dev/null +++ b/crates/libgraphql-core-v1/src/validators/union_type_validator.rs @@ -0,0 +1,102 @@ +use crate::error_note::ErrorNote; +use crate::names::TypeName; +use crate::schema::TypeValidationError; +use crate::schema::TypeValidationErrorKind; +use crate::types::GraphQLType; +use crate::types::UnionType; +use crate::validators::edit_distance::find_similar_names; +use indexmap::IndexMap; + +/// Validates a union type's member references. +/// +/// Checks that every member of a union type exists in the schema +/// and is an object type, per +/// [Union Members](https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib). +/// +/// Note: the empty-union check (`EmptyUnionType`) is a build-level +/// error handled by `SchemaBuildErrorKind`; this validator only +/// covers member-exists and member-is-object checks. +// TODO(Task 16): Wire this validator into SchemaBuilder::build(). +// Also add EmptyUnionType check in build() since it's a +// SchemaBuildErrorKind (not a TypeValidationErrorKind). +pub(crate) struct UnionTypeValidator<'a> { + errors: Vec, + type_: &'a UnionType, + types_map: &'a IndexMap, +} + +impl<'a> UnionTypeValidator<'a> { + pub fn new( + type_: &'a UnionType, + types_map: &'a IndexMap, + ) -> Self { + Self { + errors: vec![], + type_, + types_map, + } + } + + pub fn validate(mut self) -> Vec { + for member in self.type_.members() { + let member_name = &member.value; + + // Member types of a union type must be defined. + // + // https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib + let Some(member_type) = self.types_map.get(member_name) + else { + let mut notes = Vec::new(); + let suggestions = find_similar_names( + member_name.as_str(), + self.types_map.keys(), + ); + if let Some(best) = suggestions.first() { + notes.push(ErrorNote::help( + format!("did you mean `{best}`?"), + )); + } + notes.push(ErrorNote::spec( + "https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib", + )); + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::UndefinedTypeName { + undefined_type_name: + member_name.to_string(), + }, + member.span, + notes, + )); + continue; + }; + + // Member types of a union type can only be object + // types. + // + // https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib + if !matches!(member_type, GraphQLType::Object(_)) { + self.errors.push(TypeValidationError::new( + TypeValidationErrorKind::InvalidUnionMemberTypeKind { + member_name: member_name.to_string(), + union_type_name: + self.type_.name().to_string(), + }, + member.span, + vec![ + ErrorNote::general_with_span( + format!( + "`{member_name}` is defined here", + ), + member_type.span(), + ), + ErrorNote::spec( + "https://spec.graphql.org/September2025/#sel-HAHdfFDABABlG3ib", + ), + ], + )); + } + } + + self.errors + } +} diff --git a/libgraphql-core-v1-plan.md b/libgraphql-core-v1-plan.md index 7fa9174..a7b2372 100644 --- a/libgraphql-core-v1-plan.md +++ b/libgraphql-core-v1-plan.md @@ -24,6 +24,10 @@ The crate is developed as `libgraphql-core-v1` (Cargo package name) to coexist w 4. Wait for the PR to be reviewed and merged to main 5. After merge: run `sl pull && sl up main` to move to the main commit, then proceed with the next task +**When addressing code review feedback:** +- Every bug fix MUST have a corresponding regression test unless the test would be meaningfully unuseful. No bug found during review should lack a test proving it doesn't regress. +- Regression test comments should use "Regression test for **a** bug where..." (not "the bug") — future readers won't have context on which specific bug is being referenced. + This ensures the plan persistently tracks progress and evolving understanding across sessions, and each task is independently reviewed before building on it. **Goal:** Build a from-scratch rewrite of `libgraphql-core` that consumes `libgraphql-parser` AST directly, exposes public type builders, leverages Rust's type system for safety, and implements complete GraphQL September 2025 spec validation. @@ -3319,13 +3323,15 @@ pub(crate) fn validate_directive_definitions( - __ prefixed directive arg -> error - Undefined type reference -> error -- [ ] Port + fix `object_or_interface_type_validator.rs` (generic over `HasFieldsAndInterfaces`) -- [ ] Port + fix `union_type_validator.rs` (add empty union check) -- [ ] Port + fix `input_object_type_validator.rs` (use `!is_input_type()`) -- [ ] Implement new `directive_definition_validator.rs` -- [ ] Implement `type_reference_validator.rs` -- [ ] Write comprehensive validator tests (valid + invalid for each rule) -- [ ] Commit: `[libgraphql-core-v1] Add validators (object/interface, union, input, directive, type-ref)` +- [x] Port + fix `object_or_interface_type_validator.rs` (generic over `HasFieldsAndInterfaces`) +- [x] Port + fix `union_type_validator.rs` (empty union check handled at build level via `SchemaBuildErrorKind::EmptyUnionType`) +- [x] Port + fix `input_object_type_validator.rs` (use `!is_input_type()`) +- [x] Implement new `directive_definition_validator.rs` +- [x] ~~Implement `type_reference_validator.rs`~~ — removed; type reference validation is distributed across per-type validators +- [x] Write comprehensive validator tests (valid + invalid for each rule) +- [x] Commit: `[libgraphql-core-v1] Add validators (object/interface, union, input, directive)` + +**Completion Notes:** 4 validators implemented (object/interface, union, input object, directive definition). `type_reference_validator` removed — all type reference checks are covered by per-type validators. Precise error spans: interface-clause errors use the interface reference span, not the whole type span. Rich error notes: param type mismatches, required additional params, and return type mismatches all include notes pointing at the interface's definition. Fixed v0 bug: input field type check uses `!is_input_type()` to reject Interface/Union types. All identifiers in error messages wrapped in backticks. **Note:** Validators are NOT yet wired into `SchemaBuilder::build()` — that happens in Task 16. --- @@ -3613,6 +3619,7 @@ pub fn build(mut self) -> Result { - [ ] Implement `Schema` with typed query API and full rustdocs - [ ] Implement `SchemaBuilder::build()` orchestrating all validators +- [ ] Add `EmptyUnionType`, `EmptyObjectOrInterfaceType`, and `EnumWithNoValues` checks in `build()` — these are `SchemaBuildErrorKind` variants (not `TypeValidationErrorKind`), so they belong in the build pipeline rather than in per-type validators - [ ] Write end-to-end schema building tests (valid schemas, invalid schemas with specific error assertions) - [ ] Commit: `[libgraphql-core-v1] Add Schema struct and SchemaBuilder::build()` @@ -4259,6 +4266,7 @@ As part of this plan's execution, add the following item to `libgraphql-parser`' - [ ] Interface implementation: field presence, param equivalence, return covariance - [ ] Interface implementation: additional params must be optional - [ ] Interface implementation: transitive (recursive) +- [ ] Interface implementation: deprecated field consistency (IsValidImplementation step 2.f — if interface field is not deprecated, implementing field must not be deprecated) - [ ] Union members must be Object types - [ ] Input field types must be input types (not Object/Interface/Union) - [ ] Output field types must be output types