2026-02-08 13:24:35 +00:00
//! 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 {
2026-02-14 14:03:21 +00:00
| Value ::Decimal ( f ) = > {
2026-02-08 13:24:35 +00:00
// 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 ,
} ) ;
}
} ,
2026-02-14 14:03:21 +00:00
| Value ::Number ( i ) = > {
2026-02-08 13:24:35 +00:00
// 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 " {
2026-02-14 14:03:21 +00:00
if let Value ::Decimal ( f ) = field . value {
2026-02-08 13:24:35 +00:00
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 ,
} ) ;
}
}
}
}
2026-02-13 21:52:03 +00:00
// Validate participant fields
2026-02-08 13:24:35 +00:00
for participant in & rel . participants {
2026-02-13 21:52:03 +00:00
validate_trait_ranges ( & participant . fields , collector ) ;
2026-02-08 13:24:35 +00:00
}
}
}
/// 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}) " ,
2026-02-13 21:52:03 +00:00
block1 . name . as_ref ( ) . unwrap_or ( & block1 . activity ) ,
2026-02-08 13:24:35 +00:00
block1 . start . hour ,
block1 . start . minute ,
block1 . end . hour ,
block1 . end . minute
) ,
block2 : format ! (
" {} ({}:{:02}-{}:{:02}) " ,
2026-02-13 21:52:03 +00:00
block2 . name . as_ref ( ) . unwrap_or ( & block2 . activity ) ,
2026-02-08 13:24:35 +00:00
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 {
2026-02-13 21:52:03 +00:00
| BehaviorNode ::Sequence { children , .. } | BehaviorNode ::Selector { children , .. } = > {
2026-02-08 13:24:35 +00:00
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
} ,
2026-02-13 21:52:03 +00:00
| BehaviorNode ::Decorator { child , .. } = > {
2026-02-08 13:24:35 +00:00
validate_tree_node_actions ( child , action_registry , tree_name , collector ) ;
} ,
| BehaviorNode ::SubTree ( _path ) = > {
// SubTree references validated separately
} ,
}
}
2026-02-13 21:52:03 +00:00
/// 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 ( )
) ,
} ) ;
}
}
}
2026-02-08 13:24:35 +00:00
/// 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 ) ;
2026-02-13 21:52:03 +00:00
validate_character_resource_links ( c , & mut collector ) ;
} ,
| Declaration ::Institution ( i ) = > {
validate_institution_resource_links ( i , & mut collector ) ;
2026-02-08 13:24:35 +00:00
} ,
| Declaration ::Relationship ( r ) = > {
validate_relationship_bonds ( std ::slice ::from_ref ( r ) , & mut collector ) ;
} ,
| Declaration ::Schedule ( s ) = > {
validate_schedule_overlaps ( s , & mut collector ) ;
2026-02-13 21:52:03 +00:00
validate_schedule_composition ( s , & mut collector ) ;
2026-02-08 13:24:35 +00:00
} ,
| 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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Decimal ( 0.8 ) ,
2026-02-08 13:24:35 +00:00
span : Span ::new ( 0 , 10 ) ,
} ,
Field {
name : " age " . to_string ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Number ( 30 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Decimal ( 1.5 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Decimal ( - 0.1 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Number ( - 5 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Number ( 200 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Decimal ( 0.9 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-14 14:03:21 +00:00
value : Value ::Decimal ( 1.2 ) ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-08 15:45:56 +00:00
on_enter : None ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-08 15:45:56 +00:00
on_enter : None ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-08 15:45:56 +00:00
on_enter : None ,
2026-02-08 13:24:35 +00:00
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 ( ) ,
2026-02-13 21:52:03 +00:00
root : BehaviorNode ::Sequence {
label : None ,
children : vec ! [
BehaviorNode ::Action ( " walk " . to_string ( ) , vec! [ ] ) ,
BehaviorNode ::Action ( " eat " . to_string ( ) , vec! [ ] ) ,
] ,
} ,
2026-02-08 13:24:35 +00:00
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 ( ) ) ;
}
}