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:
@@ -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<String>) -> 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<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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user