From eded129438b49b78cb7157429a64d5fd5905e210 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 14 Feb 2026 14:17:40 +0000 Subject: [PATCH] feat(validate): enforce type invariance in species inheritance When a template extends a species via species_base, shared field names must have compatible types. Range values are compatible with their underlying numeric types (Number or Decimal). --- src/resolve/validate.rs | 262 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/src/resolve/validate.rs b/src/resolve/validate.rs index b22992f..750c624 100644 --- a/src/resolve/validate.rs +++ b/src/resolve/validate.rs @@ -444,6 +444,104 @@ pub fn validate_sub_concept_parents(file: &File, collector: &mut ErrorCollector) } } +/// Get the type name of a Value for error messages +fn value_type_name(value: &Value) -> &'static str { + match value { + | Value::Number(_) => "Number", + | Value::Decimal(_) => "Decimal", + | Value::Text(_) => "Text", + | Value::Boolean(_) => "Boolean", + | Value::Range(_, _) => "Range", + | Value::Time(_) => "Time", + | Value::Duration(_) => "Duration", + | Value::Identifier(_) => "Identifier", + | Value::List(_) => "List", + | Value::Object(_) => "Object", + | Value::ProseBlock(_) => "ProseBlock", + | Value::Override(_) => "Override", + | Value::Any => "Any", + } +} + +/// Check if two values have compatible types +fn values_type_compatible(a: &Value, b: &Value) -> bool { + matches!( + (a, b), + (Value::Number(_), Value::Number(_)) | + (Value::Decimal(_), Value::Decimal(_)) | + (Value::Text(_), Value::Text(_)) | + (Value::Boolean(_), Value::Boolean(_)) | + (Value::Time(_), Value::Time(_)) | + (Value::Duration(_), Value::Duration(_)) | + (Value::Identifier(_), Value::Identifier(_)) | + (Value::List(_), Value::List(_)) | + (Value::Object(_), Value::Object(_)) | + (Value::ProseBlock(_), Value::ProseBlock(_)) | + (Value::Range(_, _), Value::Range(_, _)) | + (Value::Range(_, _), Value::Number(_)) | + (Value::Number(_), Value::Range(_, _)) | + (Value::Range(_, _), Value::Decimal(_)) | + (Value::Decimal(_), Value::Range(_, _)) | + (Value::Any, _) | + (_, Value::Any) + ) +} + +/// Validate type invariance in species inheritance +/// +/// When a template extends a species, any shared field names must have +/// compatible types. For example, if species Human has `age: 0` (Number), +/// then template Person: Human cannot have `age: "old"` (Text). +pub fn validate_species_type_invariance(file: &File, collector: &mut ErrorCollector) { + for decl in &file.declarations { + if let Declaration::Template(template) = decl { + if let Some(species_name) = &template.species_base { + // Find the species declaration + let species = file.declarations.iter().find_map(|d| { + if let Declaration::Species(s) = d { + if s.name == *species_name { + return Some(s); + } + } + None + }); + + if let Some(species) = species { + // Check each template field against species fields + for template_field in &template.fields { + if let Some(species_field) = species + .fields + .iter() + .find(|f| f.name == template_field.name) + { + if !values_type_compatible(&species_field.value, &template_field.value) + { + collector.add(ResolveError::ValidationError { + message: format!( + "Field '{}' has type {} in species {} but {} in template {}", + template_field.name, + value_type_name(&species_field.value), + species_name, + value_type_name(&template_field.value), + template.name, + ), + help: Some(format!( + "Template '{}' inherits from species '{}'. Field '{}' must keep the same type ({}) as defined in the species.", + template.name, + species_name, + template_field.name, + value_type_name(&species_field.value), + )), + }); + } + } + } + } + } + } + } +} + /// Validate an entire file /// /// Collects all validation errors and returns them together instead of failing @@ -453,6 +551,7 @@ pub fn validate_file(file: &File, action_registry: &HashSet) -> Result<( // Type system validation validate_sub_concept_parents(file, &mut collector); + validate_species_type_invariance(file, &mut collector); for decl in &file.declarations { match decl { @@ -791,4 +890,167 @@ mod tests { validate_sub_concept_parents(&file, &mut collector); assert!(collector.has_errors()); } + + // ===== Type Invariance Tests ===== + + fn make_species(name: &str, fields: Vec) -> Species { + Species { + name: name.to_string(), + includes: vec![], + fields, + span: Span::new(0, 10), + } + } + + fn make_template_decl(name: &str, species_base: Option<&str>, fields: Vec) -> Template { + Template { + name: name.to_string(), + species_base: species_base.map(|s| s.to_string()), + fields, + strict: false, + includes: vec![], + uses_behaviors: None, + uses_schedule: None, + span: Span::new(0, 10), + } + } + + #[test] + fn test_type_invariance_matching_types() { + let file = File { + declarations: vec![ + Declaration::Species(make_species( + "Human", + vec![Field { + name: "age".to_string(), + value: Value::Number(0), + span: Span::new(0, 10), + }], + )), + Declaration::Template(make_template_decl( + "Person", + Some("Human"), + vec![Field { + name: "age".to_string(), + value: Value::Number(30), + span: Span::new(0, 10), + }], + )), + ], + }; + + let mut collector = ErrorCollector::new(); + validate_species_type_invariance(&file, &mut collector); + assert!(!collector.has_errors()); + } + + #[test] + fn test_type_invariance_mismatch() { + let file = File { + declarations: vec![ + Declaration::Species(make_species( + "Human", + vec![Field { + name: "age".to_string(), + value: Value::Number(0), + span: Span::new(0, 10), + }], + )), + Declaration::Template(make_template_decl( + "Person", + Some("Human"), + vec![Field { + name: "age".to_string(), + value: Value::Decimal(30.5), + span: Span::new(0, 10), + }], + )), + ], + }; + + let mut collector = ErrorCollector::new(); + validate_species_type_invariance(&file, &mut collector); + assert!(collector.has_errors()); + } + + #[test] + fn test_type_invariance_number_to_text_error() { + let file = File { + declarations: vec![ + Declaration::Species(make_species( + "Human", + vec![Field { + name: "age".to_string(), + value: Value::Number(0), + span: Span::new(0, 10), + }], + )), + Declaration::Template(make_template_decl( + "Person", + Some("Human"), + vec![Field { + name: "age".to_string(), + value: Value::Text("old".to_string()), + span: Span::new(0, 10), + }], + )), + ], + }; + + let mut collector = ErrorCollector::new(); + validate_species_type_invariance(&file, &mut collector); + assert!(collector.has_errors()); + } + + #[test] + fn test_type_invariance_range_compatible_with_number() { + let file = File { + declarations: vec![ + Declaration::Species(make_species( + "Human", + vec![Field { + name: "age".to_string(), + value: Value::Number(0), + span: Span::new(0, 10), + }], + )), + Declaration::Template(make_template_decl( + "Person", + Some("Human"), + vec![Field { + name: "age".to_string(), + value: Value::Range( + Box::new(Value::Number(18)), + Box::new(Value::Number(65)), + ), + span: Span::new(0, 10), + }], + )), + ], + }; + + let mut collector = ErrorCollector::new(); + validate_species_type_invariance(&file, &mut collector); + assert!(!collector.has_errors()); + } + + #[test] + fn test_type_invariance_no_species_base() { + // Template without species_base should not trigger validation + let file = File { + declarations: vec![Declaration::Template(make_template_decl( + "Person", + None, + vec![Field { + name: "age".to_string(), + value: Value::Text("old".to_string()), + span: Span::new(0, 10), + }], + ))], + }; + + let mut collector = ErrorCollector::new(); + validate_species_type_invariance(&file, &mut collector); + assert!(!collector.has_errors()); + } }