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).
This commit is contained in:
2026-02-14 14:17:40 +00:00
parent 5f1492a66a
commit eded129438

View File

@@ -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 /// Validate an entire file
/// ///
/// Collects all validation errors and returns them together instead of failing /// Collects all validation errors and returns them together instead of failing
@@ -453,6 +551,7 @@ pub fn validate_file(file: &File, action_registry: &HashSet<String>) -> Result<(
// Type system validation // Type system validation
validate_sub_concept_parents(file, &mut collector); validate_sub_concept_parents(file, &mut collector);
validate_species_type_invariance(file, &mut collector);
for decl in &file.declarations { for decl in &file.declarations {
match decl { match decl {
@@ -791,4 +890,167 @@ mod tests {
validate_sub_concept_parents(&file, &mut collector); validate_sub_concept_parents(&file, &mut collector);
assert!(collector.has_errors()); assert!(collector.has_errors());
} }
// ===== Type Invariance Tests =====
fn make_species(name: &str, fields: Vec<Field>) -> 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<Field>) -> 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());
}
} }