Checks that concept_comparison variant names exist in the referenced concept's enum sub-concepts and that all variants are covered (exhaustiveness checking).
1361 lines
48 KiB
Rust
1361 lines
48 KiB
Rust
//! Semantic validation for Storybook entities
|
|
//!
|
|
//! This module validates semantic constraints that can't be checked during
|
|
//! parsing:
|
|
//! - Reserved keyword conflicts in field names
|
|
//! - Trait value ranges
|
|
//! - Schedule time overlaps
|
|
//! - Life arc transition validity
|
|
//! - Behavior tree action registry checks
|
|
//! - Relationship bond values (0.0 .. 1.0)
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use crate::{
|
|
resolve::{
|
|
ErrorCollector,
|
|
ResolveError,
|
|
Result,
|
|
},
|
|
syntax::ast::*,
|
|
};
|
|
|
|
/// List of reserved keywords that cannot be used as field names
|
|
const RESERVED_KEYWORDS: &[&str] = &[
|
|
// Top-level declaration keywords
|
|
"character",
|
|
"template",
|
|
"life_arc",
|
|
"schedule",
|
|
"behavior",
|
|
"institution",
|
|
"relationship",
|
|
"location",
|
|
"species",
|
|
"enum",
|
|
// Statement keywords
|
|
"use",
|
|
"state",
|
|
"on",
|
|
"as",
|
|
"remove",
|
|
"append",
|
|
"strict",
|
|
"include",
|
|
"from",
|
|
// Expression keywords
|
|
"self",
|
|
"other",
|
|
"forall",
|
|
"exists",
|
|
"in",
|
|
"where",
|
|
"and",
|
|
"or",
|
|
"not",
|
|
"is",
|
|
"true",
|
|
"false",
|
|
];
|
|
|
|
/// Validate that field names don't conflict with reserved keywords
|
|
pub fn validate_no_reserved_keywords(fields: &[Field], collector: &mut ErrorCollector) {
|
|
for field in fields {
|
|
if RESERVED_KEYWORDS.contains(&field.name.as_str()) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Field name '{}' is a reserved keyword and cannot be used",
|
|
field.name
|
|
),
|
|
help: Some(format!(
|
|
"The name '{}' is reserved by the Storybook language. Try using a different name like '{}Type', '{}Value', or 'my{}'.",
|
|
field.name,
|
|
field.name,
|
|
field.name,
|
|
field.name.chars().next().unwrap_or('x').to_uppercase().collect::<String>() + &field.name[1..]
|
|
)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate trait values are within valid ranges
|
|
pub fn validate_trait_ranges(fields: &[Field], collector: &mut ErrorCollector) {
|
|
for field in fields {
|
|
match &field.value {
|
|
| Value::Decimal(f) => {
|
|
// Normalized trait values should be 0.0 .. 1.0
|
|
if (field.name.ends_with("_normalized") ||
|
|
field.name == "bond" ||
|
|
field.name == "trust" ||
|
|
field.name == "love") &&
|
|
!(0.0..=1.0).contains(f)
|
|
{
|
|
collector.add(ResolveError::TraitOutOfRange {
|
|
field: field.name.clone(),
|
|
value: f.to_string(),
|
|
min: 0.0,
|
|
max: 1.0,
|
|
});
|
|
}
|
|
},
|
|
| Value::Number(i) => {
|
|
// Age should be reasonable
|
|
if field.name == "age" && (*i < 0 || *i > 150) {
|
|
collector.add(ResolveError::TraitOutOfRange {
|
|
field: "age".to_string(),
|
|
value: i.to_string(),
|
|
min: 0.0,
|
|
max: 150.0,
|
|
});
|
|
}
|
|
},
|
|
| _ => {},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate relationship bond values are in [0.0, 1.0]
|
|
pub fn validate_relationship_bonds(relationships: &[Relationship], collector: &mut ErrorCollector) {
|
|
for rel in relationships {
|
|
for field in &rel.fields {
|
|
if field.name == "bond" {
|
|
if let Value::Decimal(f) = field.value {
|
|
if !(0.0..=1.0).contains(&f) {
|
|
collector.add(ResolveError::TraitOutOfRange {
|
|
field: "bond".to_string(),
|
|
value: f.to_string(),
|
|
min: 0.0,
|
|
max: 1.0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate participant fields
|
|
for participant in &rel.participants {
|
|
validate_trait_ranges(&participant.fields, collector);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate schedule blocks don't overlap in time
|
|
pub fn validate_schedule_overlaps(schedule: &Schedule, collector: &mut ErrorCollector) {
|
|
// Sort blocks by start time
|
|
let mut sorted_blocks: Vec<_> = schedule.blocks.iter().collect();
|
|
sorted_blocks.sort_by_key(|b| (b.start.hour as u32) * 60 + (b.start.minute as u32));
|
|
|
|
// Check for overlaps
|
|
for i in 0..sorted_blocks.len() {
|
|
for j in (i + 1)..sorted_blocks.len() {
|
|
let block1 = sorted_blocks[i];
|
|
let block2 = sorted_blocks[j];
|
|
|
|
let end1 = (block1.end.hour as u32) * 60 + (block1.end.minute as u32);
|
|
let start2 = (block2.start.hour as u32) * 60 + (block2.start.minute as u32);
|
|
|
|
// Check if blocks overlap
|
|
if end1 > start2 {
|
|
collector.add(ResolveError::ScheduleOverlap {
|
|
block1: format!(
|
|
"{} ({}:{:02}-{}:{:02})",
|
|
block1.name.as_ref().unwrap_or(&block1.activity),
|
|
block1.start.hour,
|
|
block1.start.minute,
|
|
block1.end.hour,
|
|
block1.end.minute
|
|
),
|
|
block2: format!(
|
|
"{} ({}:{:02}-{}:{:02})",
|
|
block2.name.as_ref().unwrap_or(&block2.activity),
|
|
block2.start.hour,
|
|
block2.start.minute,
|
|
block2.end.hour,
|
|
block2.end.minute
|
|
),
|
|
end1: format!("{}:{:02}", block1.end.hour, block1.end.minute),
|
|
start2: format!("{}:{:02}", block2.start.hour, block2.start.minute),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate life arc state machine has valid transitions
|
|
pub fn validate_life_arc_transitions(life_arc: &LifeArc, collector: &mut ErrorCollector) {
|
|
// Collect all state names
|
|
let mut state_names = HashSet::new();
|
|
for state in &life_arc.states {
|
|
state_names.insert(state.name.clone());
|
|
}
|
|
|
|
// Validate all transitions point to valid states
|
|
for state in &life_arc.states {
|
|
for transition in &state.transitions {
|
|
if !state_names.contains(&transition.to) {
|
|
let available_states = state_names
|
|
.iter()
|
|
.map(|s| format!("'{}'", s))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
|
|
collector.add(ResolveError::UnknownLifeArcState {
|
|
life_arc: life_arc.name.clone(),
|
|
state: state.name.clone(),
|
|
target: transition.to.clone(),
|
|
available_states,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warn if states have no outgoing transitions (terminal states)
|
|
// This is not an error, just informational
|
|
}
|
|
|
|
/// Validate behavior tree actions are known
|
|
///
|
|
/// If action_registry is empty, skips validation (no schema provided).
|
|
pub fn validate_behavior_tree_actions(
|
|
tree: &Behavior,
|
|
action_registry: &HashSet<String>,
|
|
collector: &mut ErrorCollector,
|
|
) {
|
|
// Skip validation if no action schema was provided
|
|
if action_registry.is_empty() {
|
|
return;
|
|
}
|
|
|
|
validate_tree_node_actions(&tree.root, action_registry, &tree.name, collector)
|
|
}
|
|
|
|
fn validate_tree_node_actions(
|
|
node: &BehaviorNode,
|
|
action_registry: &HashSet<String>,
|
|
tree_name: &str,
|
|
collector: &mut ErrorCollector,
|
|
) {
|
|
match node {
|
|
| BehaviorNode::Sequence { children, .. } | BehaviorNode::Selector { children, .. } => {
|
|
for child in children {
|
|
validate_tree_node_actions(child, action_registry, tree_name, collector);
|
|
}
|
|
},
|
|
| BehaviorNode::Action(name, _params) => {
|
|
if !action_registry.contains(name) {
|
|
collector.add(ResolveError::UnknownBehaviorAction {
|
|
tree: tree_name.to_string(),
|
|
action: name.clone(),
|
|
});
|
|
}
|
|
},
|
|
| BehaviorNode::Condition(_) => {
|
|
// Conditions are validated separately via expression validation
|
|
},
|
|
| BehaviorNode::Decorator { child, .. } => {
|
|
validate_tree_node_actions(child, action_registry, tree_name, collector);
|
|
},
|
|
| BehaviorNode::SubTree(_path) => {
|
|
// SubTree references validated separately
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Validate character resource linking
|
|
///
|
|
/// Checks:
|
|
/// 1. No duplicate behavior tree references
|
|
/// 2. Priority values are valid (handled by type system)
|
|
pub fn validate_character_resource_links(character: &Character, collector: &mut ErrorCollector) {
|
|
// Check for duplicate behavior tree references
|
|
if let Some(ref behavior_links) = character.uses_behaviors {
|
|
let mut seen_trees: HashSet<String> = HashSet::new();
|
|
|
|
for link in behavior_links {
|
|
let tree_name = link.tree.join("::");
|
|
|
|
if seen_trees.contains(&tree_name) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Character '{}' has duplicate behavior tree reference: {}",
|
|
character.name,
|
|
tree_name
|
|
),
|
|
help: Some(format!(
|
|
"The behavior tree '{}' is referenced multiple times in the uses behaviors list. Each behavior tree should only be referenced once. If you want different conditions or priorities, combine them into a single entry.",
|
|
tree_name
|
|
)),
|
|
});
|
|
}
|
|
|
|
seen_trees.insert(tree_name);
|
|
}
|
|
}
|
|
|
|
// Check for duplicate schedule references
|
|
if let Some(ref schedules) = character.uses_schedule {
|
|
let mut seen_schedules: HashSet<String> = HashSet::new();
|
|
|
|
for schedule in schedules {
|
|
if seen_schedules.contains(schedule) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Character '{}' has duplicate schedule reference: {}",
|
|
character.name,
|
|
schedule
|
|
),
|
|
help: Some(format!(
|
|
"The schedule '{}' is referenced multiple times. Each schedule should only be referenced once.",
|
|
schedule
|
|
)),
|
|
});
|
|
}
|
|
|
|
seen_schedules.insert(schedule.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate institution resource linking
|
|
pub fn validate_institution_resource_links(
|
|
institution: &Institution,
|
|
collector: &mut ErrorCollector,
|
|
) {
|
|
// Check for duplicate behavior tree references
|
|
if let Some(ref behavior_links) = institution.uses_behaviors {
|
|
let mut seen_trees: HashSet<String> = HashSet::new();
|
|
|
|
for link in behavior_links {
|
|
let tree_name = link.tree.join("::");
|
|
|
|
if seen_trees.contains(&tree_name) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Institution '{}' has duplicate behavior tree reference: {}",
|
|
institution.name,
|
|
tree_name
|
|
),
|
|
help: Some(format!(
|
|
"The behavior tree '{}' is referenced multiple times. Each behavior tree should only be referenced once.",
|
|
tree_name
|
|
)),
|
|
});
|
|
}
|
|
|
|
seen_trees.insert(tree_name);
|
|
}
|
|
}
|
|
|
|
// Check for duplicate schedule references
|
|
if let Some(ref schedules) = institution.uses_schedule {
|
|
let mut seen_schedules: HashSet<String> = HashSet::new();
|
|
|
|
for schedule in schedules {
|
|
if seen_schedules.contains(schedule) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Institution '{}' has duplicate schedule reference: {}",
|
|
institution.name, schedule
|
|
),
|
|
help: Some(format!(
|
|
"The schedule '{}' is referenced multiple times.",
|
|
schedule
|
|
)),
|
|
});
|
|
}
|
|
|
|
seen_schedules.insert(schedule.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate schedule composition requirements
|
|
///
|
|
/// Checks:
|
|
/// 1. All blocks in extended schedules have names
|
|
/// 2. Override blocks reference valid block names (requires name resolution)
|
|
pub fn validate_schedule_composition(schedule: &Schedule, collector: &mut ErrorCollector) {
|
|
// If schedule extends another, all blocks must have names for override system
|
|
if schedule.extends.is_some() {
|
|
for block in &schedule.blocks {
|
|
if block.name.is_none() && !block.activity.is_empty() {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Schedule '{}' extends another schedule but has unnamed blocks",
|
|
schedule.name
|
|
),
|
|
help: Some(
|
|
"When a schedule extends another, all blocks must have names to support the override system. Use 'block name { ... }' syntax instead of 'time -> time : activity { ... }'.".to_string()
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate that new-style blocks have action references
|
|
for block in &schedule.blocks {
|
|
if block.name.is_some() && block.action.is_none() && block.activity.is_empty() {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Schedule '{}' block '{}' missing action reference",
|
|
schedule.name,
|
|
block.name.as_ref().unwrap()
|
|
),
|
|
help: Some(
|
|
"Named blocks should specify a behavior using 'action: BehaviorName'. Example: 'block work { 9:00 -> 17:00, action: WorkBehavior }'".to_string()
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate that all sub_concepts reference existing parent concepts
|
|
pub fn validate_sub_concept_parents(file: &File, collector: &mut ErrorCollector) {
|
|
// Collect all concept names
|
|
let concept_names: HashSet<String> = file
|
|
.declarations
|
|
.iter()
|
|
.filter_map(|decl| {
|
|
if let Declaration::Concept(c) = decl {
|
|
Some(c.name.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Check each sub_concept references an existing parent
|
|
for decl in &file.declarations {
|
|
if let Declaration::SubConcept(sc) = decl {
|
|
if !concept_names.contains(&sc.parent_concept) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Parent concept '{}' not found for sub_concept '{}.{}'",
|
|
sc.parent_concept, sc.parent_concept, sc.name
|
|
),
|
|
help: Some(format!(
|
|
"Add 'concept {}' before defining sub_concepts for it.",
|
|
sc.parent_concept
|
|
)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 concept_comparison patterns against the concept registry
|
|
///
|
|
/// Checks:
|
|
/// 1. The concept_comparison name references an existing concept
|
|
/// 2. All variant names in patterns exist in the concept's enum sub-concepts
|
|
/// 3. Patterns are exhaustive (all variants are covered)
|
|
pub fn validate_concept_comparison_patterns(
|
|
file: &File,
|
|
registry: &crate::resolve::types::ConceptRegistry,
|
|
collector: &mut ErrorCollector,
|
|
) {
|
|
for decl in &file.declarations {
|
|
if let Declaration::ConceptComparison(comp) = decl {
|
|
// Check that the concept exists
|
|
let concept = match registry.lookup_concept(&comp.name) {
|
|
| Some(c) => c,
|
|
| None => {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Concept '{}' not found for concept_comparison",
|
|
comp.name
|
|
),
|
|
help: Some(format!(
|
|
"Add 'concept {}' before defining a concept_comparison for it.",
|
|
comp.name
|
|
)),
|
|
});
|
|
continue;
|
|
},
|
|
};
|
|
|
|
// Collect all enum variants from all enum sub-concepts
|
|
let mut all_variants: HashSet<String> = HashSet::new();
|
|
for sc_info in concept.sub_concepts.values() {
|
|
if let SubConceptKind::Enum { variants } = &sc_info.kind {
|
|
for variant in variants {
|
|
all_variants.insert(variant.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check each variant pattern references valid variants
|
|
let mut covered_variants: HashSet<String> = HashSet::new();
|
|
for pattern in &comp.variants {
|
|
// Check that the variant name is a known variant
|
|
if !all_variants.contains(&pattern.name) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Variant '{}' not found in concept '{}'",
|
|
pattern.name, comp.name
|
|
),
|
|
help: Some(format!(
|
|
"Available variants for '{}': {}",
|
|
comp.name,
|
|
if all_variants.is_empty() {
|
|
"(none - add sub_concept with enum variants)".to_string()
|
|
} else {
|
|
let mut sorted: Vec<_> = all_variants.iter().collect();
|
|
sorted.sort();
|
|
sorted
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
)),
|
|
});
|
|
} else {
|
|
covered_variants.insert(pattern.name.clone());
|
|
}
|
|
|
|
// Check field conditions reference valid fields in the Is values
|
|
for condition in &pattern.conditions {
|
|
if let Condition::Is(values) = &condition.condition {
|
|
for value in values {
|
|
// Check that the value references a valid variant
|
|
if !all_variants.contains(value) {
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"Value '{}' in condition for variant '{}' not found in concept '{}'",
|
|
value, pattern.name, comp.name
|
|
),
|
|
help: Some(format!(
|
|
"Available variants: {}",
|
|
{
|
|
let mut sorted: Vec<_> = all_variants.iter().collect();
|
|
sorted.sort();
|
|
sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
|
|
}
|
|
)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check exhaustiveness - all variants should be covered
|
|
let uncovered: Vec<_> = all_variants.difference(&covered_variants).collect();
|
|
if !uncovered.is_empty() {
|
|
let mut sorted: Vec<_> = uncovered.into_iter().collect();
|
|
sorted.sort();
|
|
collector.add(ResolveError::ValidationError {
|
|
message: format!(
|
|
"concept_comparison '{}' is not exhaustive: missing variants {}",
|
|
comp.name,
|
|
sorted
|
|
.iter()
|
|
.map(|s| format!("'{}'", s))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
help: Some(format!(
|
|
"Add patterns for all variants of concept '{}'. Missing: {}",
|
|
comp.name,
|
|
sorted
|
|
.iter()
|
|
.map(|s| s.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate an entire file
|
|
///
|
|
/// Collects all validation errors and returns them together instead of failing
|
|
/// fast.
|
|
pub fn validate_file(file: &File, action_registry: &HashSet<String>) -> Result<()> {
|
|
let mut collector = ErrorCollector::new();
|
|
|
|
// Type system validation
|
|
validate_sub_concept_parents(file, &mut collector);
|
|
validate_species_type_invariance(file, &mut collector);
|
|
let concept_registry = crate::resolve::types::ConceptRegistry::from_file(file);
|
|
validate_concept_comparison_patterns(file, &concept_registry, &mut collector);
|
|
|
|
for decl in &file.declarations {
|
|
match decl {
|
|
| Declaration::Character(c) => {
|
|
validate_trait_ranges(&c.fields, &mut collector);
|
|
validate_character_resource_links(c, &mut collector);
|
|
},
|
|
| Declaration::Institution(i) => {
|
|
validate_institution_resource_links(i, &mut collector);
|
|
},
|
|
| Declaration::Relationship(r) => {
|
|
validate_relationship_bonds(std::slice::from_ref(r), &mut collector);
|
|
},
|
|
| Declaration::Schedule(s) => {
|
|
validate_schedule_overlaps(s, &mut collector);
|
|
validate_schedule_composition(s, &mut collector);
|
|
},
|
|
| Declaration::LifeArc(la) => {
|
|
validate_life_arc_transitions(la, &mut collector);
|
|
},
|
|
| Declaration::Behavior(bt) => {
|
|
validate_behavior_tree_actions(bt, action_registry, &mut collector);
|
|
},
|
|
| _ => {
|
|
// Other declarations don't need validation yet
|
|
},
|
|
}
|
|
}
|
|
|
|
collector.into_result(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_valid_trait_ranges() {
|
|
let fields = vec![
|
|
Field {
|
|
name: "bond".to_string(),
|
|
value: Value::Decimal(0.8),
|
|
span: Span::new(0, 10),
|
|
},
|
|
Field {
|
|
name: "age".to_string(),
|
|
value: Value::Number(30),
|
|
span: Span::new(0, 10),
|
|
},
|
|
];
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_trait_ranges(&fields, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_bond_value_too_high() {
|
|
let fields = vec![Field {
|
|
name: "bond".to_string(),
|
|
value: Value::Decimal(1.5),
|
|
span: Span::new(0, 10),
|
|
}];
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_trait_ranges(&fields, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_bond_value_negative() {
|
|
let fields = vec![Field {
|
|
name: "bond".to_string(),
|
|
value: Value::Decimal(-0.1),
|
|
span: Span::new(0, 10),
|
|
}];
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_trait_ranges(&fields, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_age_negative() {
|
|
let fields = vec![Field {
|
|
name: "age".to_string(),
|
|
value: Value::Number(-5),
|
|
span: Span::new(0, 10),
|
|
}];
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_trait_ranges(&fields, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_age_too_high() {
|
|
let fields = vec![Field {
|
|
name: "age".to_string(),
|
|
value: Value::Number(200),
|
|
span: Span::new(0, 10),
|
|
}];
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_trait_ranges(&fields, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_relationship_bond() {
|
|
let relationship = Relationship {
|
|
name: "Test".to_string(),
|
|
participants: vec![],
|
|
fields: vec![Field {
|
|
name: "bond".to_string(),
|
|
value: Value::Decimal(0.9),
|
|
span: Span::new(0, 10),
|
|
}],
|
|
span: Span::new(0, 100),
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_relationship_bonds(&[relationship], &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_relationship_bond() {
|
|
let relationship = Relationship {
|
|
name: "Test".to_string(),
|
|
participants: vec![],
|
|
fields: vec![Field {
|
|
name: "bond".to_string(),
|
|
value: Value::Decimal(1.2),
|
|
span: Span::new(0, 10),
|
|
}],
|
|
span: Span::new(0, 100),
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_relationship_bonds(&[relationship], &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_life_arc_valid_transitions() {
|
|
let life_arc = LifeArc {
|
|
name: "Test".to_string(),
|
|
states: vec![
|
|
ArcState {
|
|
name: "start".to_string(),
|
|
on_enter: None,
|
|
transitions: vec![Transition {
|
|
to: "end".to_string(),
|
|
condition: Expr::Identifier(vec!["ready".to_string()]),
|
|
span: Span::new(0, 10),
|
|
}],
|
|
span: Span::new(0, 50),
|
|
},
|
|
ArcState {
|
|
name: "end".to_string(),
|
|
on_enter: None,
|
|
transitions: vec![],
|
|
span: Span::new(50, 100),
|
|
},
|
|
],
|
|
span: Span::new(0, 100),
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_life_arc_transitions(&life_arc, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_life_arc_invalid_transition() {
|
|
let life_arc = LifeArc {
|
|
name: "Test".to_string(),
|
|
states: vec![ArcState {
|
|
name: "start".to_string(),
|
|
on_enter: None,
|
|
transitions: vec![Transition {
|
|
to: "nonexistent".to_string(),
|
|
condition: Expr::Identifier(vec!["ready".to_string()]),
|
|
span: Span::new(0, 10),
|
|
}],
|
|
span: Span::new(0, 50),
|
|
}],
|
|
span: Span::new(0, 100),
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_life_arc_transitions(&life_arc, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavior_tree_valid_actions() {
|
|
let mut registry = HashSet::new();
|
|
registry.insert("walk".to_string());
|
|
registry.insert("eat".to_string());
|
|
|
|
let tree = Behavior {
|
|
name: "Test".to_string(),
|
|
root: BehaviorNode::Sequence {
|
|
label: None,
|
|
children: vec![
|
|
BehaviorNode::Action("walk".to_string(), vec![]),
|
|
BehaviorNode::Action("eat".to_string(), vec![]),
|
|
],
|
|
},
|
|
span: Span::new(0, 100),
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_behavior_tree_actions(&tree, ®istry, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavior_tree_invalid_action() {
|
|
// Create a registry with some actions (but not "unknown_action")
|
|
let mut registry = HashSet::new();
|
|
registry.insert("walk".to_string());
|
|
registry.insert("work".to_string());
|
|
|
|
let tree = Behavior {
|
|
name: "Test".to_string(),
|
|
root: BehaviorNode::Action("unknown_action".to_string(), vec![]),
|
|
span: Span::new(0, 100),
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_behavior_tree_actions(&tree, ®istry, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sub_concept_with_valid_parent() {
|
|
let file = File {
|
|
declarations: vec![
|
|
Declaration::Concept(ConceptDecl {
|
|
name: "Cup".to_string(),
|
|
span: Span::new(0, 10),
|
|
}),
|
|
Declaration::SubConcept(SubConceptDecl {
|
|
name: "Type".to_string(),
|
|
parent_concept: "Cup".to_string(),
|
|
kind: SubConceptKind::Enum {
|
|
variants: vec![
|
|
"Small".to_string(),
|
|
"Medium".to_string(),
|
|
"Large".to_string(),
|
|
],
|
|
},
|
|
span: Span::new(20, 60),
|
|
}),
|
|
],
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_sub_concept_parents(&file, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sub_concept_with_missing_parent() {
|
|
let file = File {
|
|
declarations: vec![Declaration::SubConcept(SubConceptDecl {
|
|
name: "Type".to_string(),
|
|
parent_concept: "Cup".to_string(),
|
|
kind: SubConceptKind::Enum {
|
|
variants: vec!["Small".to_string()],
|
|
},
|
|
span: Span::new(0, 40),
|
|
})],
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_sub_concept_parents(&file, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_sub_concepts_same_valid_parent() {
|
|
let file = File {
|
|
declarations: vec![
|
|
Declaration::Concept(ConceptDecl {
|
|
name: "Cup".to_string(),
|
|
span: Span::new(0, 10),
|
|
}),
|
|
Declaration::SubConcept(SubConceptDecl {
|
|
name: "Type".to_string(),
|
|
parent_concept: "Cup".to_string(),
|
|
kind: SubConceptKind::Enum {
|
|
variants: vec!["Mug".to_string()],
|
|
},
|
|
span: Span::new(20, 40),
|
|
}),
|
|
Declaration::SubConcept(SubConceptDecl {
|
|
name: "Material".to_string(),
|
|
parent_concept: "Cup".to_string(),
|
|
kind: SubConceptKind::Enum {
|
|
variants: vec!["Glass".to_string()],
|
|
},
|
|
span: Span::new(50, 70),
|
|
}),
|
|
],
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
validate_sub_concept_parents(&file, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sub_concept_wrong_parent_name() {
|
|
let file = File {
|
|
declarations: vec![
|
|
Declaration::Concept(ConceptDecl {
|
|
name: "Plate".to_string(),
|
|
span: Span::new(0, 10),
|
|
}),
|
|
Declaration::SubConcept(SubConceptDecl {
|
|
name: "Type".to_string(),
|
|
parent_concept: "Cup".to_string(),
|
|
kind: SubConceptKind::Enum {
|
|
variants: vec!["Small".to_string()],
|
|
},
|
|
span: Span::new(20, 40),
|
|
}),
|
|
],
|
|
};
|
|
|
|
let mut collector = ErrorCollector::new();
|
|
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());
|
|
}
|
|
|
|
// ===== Concept Comparison Validation Tests =====
|
|
|
|
use crate::resolve::types::ConceptRegistry;
|
|
|
|
fn make_concept_comparison_file() -> File {
|
|
File {
|
|
declarations: vec![
|
|
Declaration::Concept(ConceptDecl {
|
|
name: "Cup".to_string(),
|
|
span: Span::new(0, 10),
|
|
}),
|
|
Declaration::SubConcept(SubConceptDecl {
|
|
name: "Type".to_string(),
|
|
parent_concept: "Cup".to_string(),
|
|
kind: SubConceptKind::Enum {
|
|
variants: vec![
|
|
"Small".to_string(),
|
|
"Medium".to_string(),
|
|
"Large".to_string(),
|
|
],
|
|
},
|
|
span: Span::new(20, 60),
|
|
}),
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_concept_comparison_valid_exhaustive() {
|
|
let mut file = make_concept_comparison_file();
|
|
file.declarations
|
|
.push(Declaration::ConceptComparison(ConceptComparisonDecl {
|
|
name: "Cup".to_string(),
|
|
variants: vec![
|
|
VariantPattern {
|
|
name: "Small".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Medium".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Large".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
],
|
|
span: Span::new(0, 100),
|
|
}));
|
|
|
|
let registry = ConceptRegistry::from_file(&file);
|
|
let mut collector = ErrorCollector::new();
|
|
validate_concept_comparison_patterns(&file, ®istry, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_concept_comparison_not_exhaustive() {
|
|
let mut file = make_concept_comparison_file();
|
|
file.declarations
|
|
.push(Declaration::ConceptComparison(ConceptComparisonDecl {
|
|
name: "Cup".to_string(),
|
|
variants: vec![
|
|
VariantPattern {
|
|
name: "Small".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
// Missing Medium and Large
|
|
],
|
|
span: Span::new(0, 100),
|
|
}));
|
|
|
|
let registry = ConceptRegistry::from_file(&file);
|
|
let mut collector = ErrorCollector::new();
|
|
validate_concept_comparison_patterns(&file, ®istry, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_concept_comparison_unknown_variant() {
|
|
let mut file = make_concept_comparison_file();
|
|
file.declarations
|
|
.push(Declaration::ConceptComparison(ConceptComparisonDecl {
|
|
name: "Cup".to_string(),
|
|
variants: vec![
|
|
VariantPattern {
|
|
name: "Small".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Medium".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Large".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Huge".to_string(), // Not a valid variant
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
],
|
|
span: Span::new(0, 100),
|
|
}));
|
|
|
|
let registry = ConceptRegistry::from_file(&file);
|
|
let mut collector = ErrorCollector::new();
|
|
validate_concept_comparison_patterns(&file, ®istry, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_concept_comparison_missing_concept() {
|
|
let file = File {
|
|
declarations: vec![Declaration::ConceptComparison(ConceptComparisonDecl {
|
|
name: "NonExistent".to_string(),
|
|
variants: vec![],
|
|
span: Span::new(0, 100),
|
|
})],
|
|
};
|
|
|
|
let registry = ConceptRegistry::from_file(&file);
|
|
let mut collector = ErrorCollector::new();
|
|
validate_concept_comparison_patterns(&file, ®istry, &mut collector);
|
|
assert!(collector.has_errors());
|
|
}
|
|
|
|
#[test]
|
|
fn test_concept_comparison_with_field_conditions() {
|
|
let mut file = make_concept_comparison_file();
|
|
file.declarations
|
|
.push(Declaration::ConceptComparison(ConceptComparisonDecl {
|
|
name: "Cup".to_string(),
|
|
variants: vec![
|
|
VariantPattern {
|
|
name: "Small".to_string(),
|
|
conditions: vec![FieldCondition {
|
|
field_name: "material".to_string(),
|
|
condition: Condition::Is(vec!["Medium".to_string()]), // Valid variant ref
|
|
span: Span::new(0, 10),
|
|
}],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Medium".to_string(),
|
|
conditions: vec![FieldCondition {
|
|
field_name: "material".to_string(),
|
|
condition: Condition::Any,
|
|
span: Span::new(0, 10),
|
|
}],
|
|
span: Span::new(0, 10),
|
|
},
|
|
VariantPattern {
|
|
name: "Large".to_string(),
|
|
conditions: vec![],
|
|
span: Span::new(0, 10),
|
|
},
|
|
],
|
|
span: Span::new(0, 100),
|
|
}));
|
|
|
|
let registry = ConceptRegistry::from_file(&file);
|
|
let mut collector = ErrorCollector::new();
|
|
validate_concept_comparison_patterns(&file, ®istry, &mut collector);
|
|
assert!(!collector.has_errors());
|
|
}
|
|
}
|