feat: implement storybook DSL with template composition and validation
Add complete domain-specific language for authoring narrative content for agent simulations. Features: - Complete parser using LALRPOP + logos lexer - Template composition (includes + multiple inheritance) - Strict mode validation for templates - Reserved keyword protection - Semantic validators (trait ranges, schedule overlaps, life arcs, behaviors) - Name resolution and cross-reference tracking - CLI tool (validate, inspect, query commands) - Query API with filtering - 260 comprehensive tests (unit, integration, property-based) Implementation phases: - Phase 1 (Parser): Complete - Phase 2 (Resolution + Validation): Complete - Phase 3 (Public API + CLI): Complete BREAKING CHANGE: Initial implementation
This commit is contained in:
501
src/resolve/validate.rs
Normal file
501
src/resolve/validate.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
//! 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::Float(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::Int(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::Float(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 self/other blocks if present
|
||||
for participant in &rel.participants {
|
||||
if let Some(ref self_fields) = participant.self_block {
|
||||
validate_trait_ranges(self_fields, collector);
|
||||
}
|
||||
if let Some(ref other_fields) = participant.other_block {
|
||||
validate_trait_ranges(other_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.activity,
|
||||
block1.start.hour,
|
||||
block1.start.minute,
|
||||
block1.end.hour,
|
||||
block1.end.minute
|
||||
),
|
||||
block2: format!(
|
||||
"{} ({}:{:02}-{}:{:02})",
|
||||
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(_name, child) => {
|
||||
validate_tree_node_actions(child, action_registry, tree_name, collector);
|
||||
},
|
||||
| BehaviorNode::SubTree(_path) => {
|
||||
// SubTree references validated separately
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
},
|
||||
| Declaration::Relationship(r) => {
|
||||
validate_relationship_bonds(std::slice::from_ref(r), &mut collector);
|
||||
},
|
||||
| Declaration::Schedule(s) => {
|
||||
validate_schedule_overlaps(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::Float(0.8),
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
Field {
|
||||
name: "age".to_string(),
|
||||
value: Value::Int(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::Float(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::Float(-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::Int(-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::Int(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::Float(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::Float(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(),
|
||||
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(),
|
||||
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(),
|
||||
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(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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user