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