Files
storybook/src/resolve/validate.rs

656 lines
22 KiB
Rust
Raw Normal View History

//! 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 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();
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, &registry, &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, &registry, &mut collector);
assert!(collector.has_errors());
}
}