710 lines
23 KiB
Rust
710 lines
23 KiB
Rust
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
use std::fmt;
|
|
use std::fmt::Write as _;
|
|
|
|
use decodal::{Engine, ImportLoader, LoadedImport};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
pub const FLOW_SCHEMA_VERSION: u32 = 1;
|
|
pub const CANCELLED_STATE_ID: &str = "$cancelled";
|
|
pub const CANCEL_TRANSITION_ID: &str = "$cancel";
|
|
pub const CANCEL_CONDITION: &str = "An exceptional condition makes it impossible to continue the current instructions and reach a normal terminal state with the available tools, scope, and session context.";
|
|
|
|
const MAX_SOURCE_BYTES: usize = 256 * 1024;
|
|
const MAX_STATES: usize = 128;
|
|
const MAX_TRANSITIONS_PER_STATE: usize = 32;
|
|
const MAX_TEXT_BYTES: usize = 32 * 1024;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct StateId(String);
|
|
|
|
impl StateId {
|
|
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
|
let value = value.into();
|
|
validate_identifier("state", &value)?;
|
|
Ok(Self(value))
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for StateId {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct TransitionId(String);
|
|
|
|
impl TransitionId {
|
|
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
|
let value = value.into();
|
|
validate_identifier("transition", &value)?;
|
|
Ok(Self(value))
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for TransitionId {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CompiledFlowDefinition {
|
|
pub schema_version: u32,
|
|
pub name: String,
|
|
pub initial: StateId,
|
|
pub states: BTreeMap<StateId, CompiledState>,
|
|
pub content_digest: String,
|
|
}
|
|
|
|
impl CompiledFlowDefinition {
|
|
pub fn state(&self, state_id: &StateId) -> Option<&CompiledState> {
|
|
self.states.get(state_id)
|
|
}
|
|
|
|
pub fn outgoing(&self, state_id: &StateId) -> Option<&[CompiledTransition]> {
|
|
self.state(state_id)
|
|
.map(|state| state.transitions.as_slice())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CompiledState {
|
|
pub id: StateId,
|
|
pub instructions: String,
|
|
pub terminal: bool,
|
|
pub transitions: Vec<CompiledTransition>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CompiledTransition {
|
|
pub id: TransitionId,
|
|
pub target: StateId,
|
|
pub condition: String,
|
|
pub synthetic: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowDiagnostic {
|
|
pub code: String,
|
|
pub path: String,
|
|
pub message: String,
|
|
}
|
|
|
|
impl FlowDiagnostic {
|
|
fn new(code: impl Into<String>, path: impl Into<String>, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code: code.into(),
|
|
path: path.into(),
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
|
|
#[error("Flow definition is invalid")]
|
|
pub struct FlowCompileError {
|
|
pub diagnostics: Vec<FlowDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct FlowSource {
|
|
schema_version: u32,
|
|
name: String,
|
|
initial: String,
|
|
states: BTreeMap<String, StateSource>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct StateSource {
|
|
instructions: String,
|
|
#[serde(default)]
|
|
terminal: bool,
|
|
#[serde(default)]
|
|
transitions: BTreeMap<String, TransitionSource>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct TransitionSource {
|
|
target: String,
|
|
condition: String,
|
|
}
|
|
|
|
pub fn compile_flow_source(content: &str) -> Result<CompiledFlowDefinition, FlowCompileError> {
|
|
if content.len() > MAX_SOURCE_BYTES {
|
|
return Err(one_diagnostic(
|
|
"source_too_large",
|
|
"$",
|
|
format!("Flow source exceeds {MAX_SOURCE_BYTES} bytes"),
|
|
));
|
|
}
|
|
|
|
let mut engine = Engine::new(RejectImports);
|
|
let module = engine
|
|
.add_root_source("flow.dcdl", "flow.dcdl", content)
|
|
.map_err(|error| {
|
|
one_diagnostic("dcdl_parse", "$", format!("DCDL parsing failed: {error:?}"))
|
|
})?;
|
|
let value = engine.eval_module(module).map_err(|error| {
|
|
one_diagnostic(
|
|
"dcdl_evaluation",
|
|
"$",
|
|
format!("DCDL evaluation failed: {error:?}"),
|
|
)
|
|
})?;
|
|
let data = engine.materialize(&value).map_err(|error| {
|
|
one_diagnostic(
|
|
"dcdl_materialization",
|
|
"$",
|
|
format!("DCDL materialization failed: {error:?}"),
|
|
)
|
|
})?;
|
|
let value = decodal_data_to_json(&data);
|
|
let source = serde_json::from_value::<FlowSource>(value).map_err(|error| {
|
|
one_diagnostic(
|
|
"schema_decode",
|
|
"$",
|
|
format!("Flow source does not match schema: {error}"),
|
|
)
|
|
})?;
|
|
compile_typed_source(source, content)
|
|
}
|
|
|
|
fn compile_typed_source(
|
|
source: FlowSource,
|
|
content: &str,
|
|
) -> Result<CompiledFlowDefinition, FlowCompileError> {
|
|
let mut diagnostics = Vec::new();
|
|
if source.schema_version != FLOW_SCHEMA_VERSION {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"unsupported_schema_version",
|
|
"schema_version",
|
|
format!(
|
|
"unsupported Flow schema version {}; expected {FLOW_SCHEMA_VERSION}",
|
|
source.schema_version
|
|
),
|
|
));
|
|
}
|
|
if let Err(message) = validate_identifier("Flow", &source.name) {
|
|
diagnostics.push(FlowDiagnostic::new("invalid_name", "name", message));
|
|
}
|
|
if source.states.is_empty() {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"states_empty",
|
|
"states",
|
|
"Flow must declare at least one state",
|
|
));
|
|
}
|
|
if source.states.len() > MAX_STATES {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"too_many_states",
|
|
"states",
|
|
format!("Flow declares more than {MAX_STATES} states"),
|
|
));
|
|
}
|
|
if source.initial == CANCELLED_STATE_ID {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"reserved_state",
|
|
"initial",
|
|
format!("{CANCELLED_STATE_ID} is reserved by the Flow runtime"),
|
|
));
|
|
}
|
|
|
|
let initial = StateId::new(source.initial.clone()).unwrap_or_else(|message| {
|
|
diagnostics.push(FlowDiagnostic::new("invalid_initial", "initial", message));
|
|
StateId(source.initial.clone())
|
|
});
|
|
|
|
let declared_names = source.states.keys().cloned().collect::<BTreeSet<_>>();
|
|
if !declared_names.contains(initial.as_str()) {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"initial_not_found",
|
|
"initial",
|
|
format!("initial state {initial:?} is not declared"),
|
|
));
|
|
}
|
|
if declared_names.contains(CANCELLED_STATE_ID) {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"reserved_state",
|
|
format!("states.{CANCELLED_STATE_ID}"),
|
|
format!("{CANCELLED_STATE_ID} is reserved by the Flow runtime"),
|
|
));
|
|
}
|
|
|
|
let mut states = BTreeMap::new();
|
|
for (state_name, state_source) in source.states {
|
|
let state_path = format!("states.{state_name}");
|
|
let state_id = StateId::new(state_name.clone()).unwrap_or_else(|message| {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"invalid_state_id",
|
|
state_path.clone(),
|
|
message,
|
|
));
|
|
StateId(state_name.clone())
|
|
});
|
|
validate_text(
|
|
&mut diagnostics,
|
|
"instructions",
|
|
&format!("{state_path}.instructions"),
|
|
&state_source.instructions,
|
|
state_source.terminal,
|
|
);
|
|
if state_source.terminal && !state_source.transitions.is_empty() {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"terminal_has_transitions",
|
|
format!("{state_path}.transitions"),
|
|
"terminal states must not declare outgoing transitions",
|
|
));
|
|
}
|
|
if !state_source.terminal && state_source.transitions.is_empty() {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"non_terminal_without_transition",
|
|
format!("{state_path}.transitions"),
|
|
"non-terminal states must declare at least one transition",
|
|
));
|
|
}
|
|
if state_source.transitions.len() > MAX_TRANSITIONS_PER_STATE {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"too_many_transitions",
|
|
format!("{state_path}.transitions"),
|
|
format!("state declares more than {MAX_TRANSITIONS_PER_STATE} transitions"),
|
|
));
|
|
}
|
|
|
|
let mut transitions = Vec::new();
|
|
for (transition_name, transition_source) in state_source.transitions {
|
|
let transition_path = format!("{state_path}.transitions.{transition_name}");
|
|
if transition_name == CANCEL_TRANSITION_ID || transition_name.starts_with('$') {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"reserved_transition",
|
|
transition_path.clone(),
|
|
"transition identifiers beginning with '$' are reserved by the Flow runtime",
|
|
));
|
|
}
|
|
let transition_id =
|
|
TransitionId::new(transition_name.clone()).unwrap_or_else(|message| {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"invalid_transition_id",
|
|
transition_path.clone(),
|
|
message,
|
|
));
|
|
TransitionId(transition_name.clone())
|
|
});
|
|
if transition_source.target == CANCELLED_STATE_ID
|
|
|| transition_source.target.starts_with('$')
|
|
{
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"reserved_transition_target",
|
|
format!("{transition_path}.target"),
|
|
"definition authors cannot target runtime-reserved states",
|
|
));
|
|
}
|
|
let target = StateId::new(transition_source.target.clone()).unwrap_or_else(|message| {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"invalid_transition_target",
|
|
format!("{transition_path}.target"),
|
|
message,
|
|
));
|
|
StateId(transition_source.target.clone())
|
|
});
|
|
if !declared_names.contains(target.as_str()) {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"transition_target_not_found",
|
|
format!("{transition_path}.target"),
|
|
format!("transition target {target:?} is not declared"),
|
|
));
|
|
}
|
|
validate_text(
|
|
&mut diagnostics,
|
|
"condition",
|
|
&format!("{transition_path}.condition"),
|
|
&transition_source.condition,
|
|
false,
|
|
);
|
|
transitions.push(CompiledTransition {
|
|
id: transition_id,
|
|
target,
|
|
condition: transition_source.condition,
|
|
synthetic: false,
|
|
});
|
|
}
|
|
if !state_source.terminal {
|
|
transitions.push(CompiledTransition {
|
|
id: TransitionId(CANCEL_TRANSITION_ID.to_string()),
|
|
target: StateId(CANCELLED_STATE_ID.to_string()),
|
|
condition: CANCEL_CONDITION.to_string(),
|
|
synthetic: true,
|
|
});
|
|
}
|
|
states.insert(
|
|
state_id.clone(),
|
|
CompiledState {
|
|
id: state_id,
|
|
instructions: state_source.instructions,
|
|
terminal: state_source.terminal,
|
|
transitions,
|
|
},
|
|
);
|
|
}
|
|
|
|
if diagnostics.is_empty() {
|
|
validate_graph(&states, &initial, &mut diagnostics);
|
|
}
|
|
if !diagnostics.is_empty() {
|
|
return Err(FlowCompileError { diagnostics });
|
|
}
|
|
|
|
states.insert(
|
|
StateId(CANCELLED_STATE_ID.to_string()),
|
|
CompiledState {
|
|
id: StateId(CANCELLED_STATE_ID.to_string()),
|
|
instructions: String::new(),
|
|
terminal: true,
|
|
transitions: Vec::new(),
|
|
},
|
|
);
|
|
|
|
Ok(CompiledFlowDefinition {
|
|
schema_version: source.schema_version,
|
|
name: source.name,
|
|
initial,
|
|
states,
|
|
content_digest: content_digest(content),
|
|
})
|
|
}
|
|
|
|
fn validate_identifier(kind: &str, value: &str) -> Result<(), String> {
|
|
if value.is_empty() {
|
|
return Err(format!("{kind} identifier must not be empty"));
|
|
}
|
|
if value.len() > 128 {
|
|
return Err(format!("{kind} identifier exceeds 128 bytes"));
|
|
}
|
|
if value.starts_with('$') {
|
|
return Err(format!("{kind} identifier beginning with '$' is reserved"));
|
|
}
|
|
if !value
|
|
.chars()
|
|
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
|
{
|
|
return Err(format!(
|
|
"{kind} identifier must contain only ASCII letters, digits, '-' or '_'"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_text(
|
|
diagnostics: &mut Vec<FlowDiagnostic>,
|
|
field: &str,
|
|
path: &str,
|
|
value: &str,
|
|
allow_empty: bool,
|
|
) {
|
|
if !allow_empty && value.trim().is_empty() {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
format!("{field}_empty"),
|
|
path,
|
|
format!("{field} must not be empty"),
|
|
));
|
|
}
|
|
if value.len() > MAX_TEXT_BYTES {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
format!("{field}_too_large"),
|
|
path,
|
|
format!("{field} exceeds {MAX_TEXT_BYTES} bytes"),
|
|
));
|
|
}
|
|
}
|
|
|
|
fn validate_graph(
|
|
states: &BTreeMap<StateId, CompiledState>,
|
|
initial: &StateId,
|
|
diagnostics: &mut Vec<FlowDiagnostic>,
|
|
) {
|
|
if !states.contains_key(initial) {
|
|
return;
|
|
}
|
|
let mut reachable = BTreeSet::new();
|
|
let mut pending = VecDeque::from([initial.clone()]);
|
|
while let Some(current) = pending.pop_front() {
|
|
if !reachable.insert(current.clone()) {
|
|
continue;
|
|
}
|
|
if let Some(state) = states.get(¤t) {
|
|
for transition in state
|
|
.transitions
|
|
.iter()
|
|
.filter(|transition| !transition.synthetic)
|
|
{
|
|
pending.push_back(transition.target.clone());
|
|
}
|
|
}
|
|
}
|
|
for state_id in states.keys() {
|
|
if !reachable.contains(state_id) {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"unreachable_state",
|
|
format!("states.{state_id}"),
|
|
format!("state {state_id:?} is unreachable from initial state {initial:?}"),
|
|
));
|
|
}
|
|
}
|
|
|
|
let terminal_states = states
|
|
.values()
|
|
.filter(|state| state.terminal)
|
|
.map(|state| state.id.clone())
|
|
.collect::<BTreeSet<_>>();
|
|
if terminal_states.is_empty() {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"terminal_missing",
|
|
"states",
|
|
"Flow must declare at least one terminal state",
|
|
));
|
|
return;
|
|
}
|
|
|
|
let mut reverse: BTreeMap<StateId, Vec<StateId>> = BTreeMap::new();
|
|
for state in states.values() {
|
|
for transition in state
|
|
.transitions
|
|
.iter()
|
|
.filter(|transition| !transition.synthetic)
|
|
{
|
|
reverse
|
|
.entry(transition.target.clone())
|
|
.or_default()
|
|
.push(state.id.clone());
|
|
}
|
|
}
|
|
let mut can_reach_terminal = terminal_states.clone();
|
|
let mut pending = terminal_states.into_iter().collect::<VecDeque<_>>();
|
|
while let Some(current) = pending.pop_front() {
|
|
for predecessor in reverse.get(¤t).into_iter().flatten() {
|
|
if can_reach_terminal.insert(predecessor.clone()) {
|
|
pending.push_back(predecessor.clone());
|
|
}
|
|
}
|
|
}
|
|
for state_id in reachable {
|
|
if !can_reach_terminal.contains(&state_id) {
|
|
diagnostics.push(FlowDiagnostic::new(
|
|
"terminal_unreachable",
|
|
format!("states.{state_id}"),
|
|
format!(
|
|
"state {state_id:?} cannot reach a user-declared terminal state; the graph contains a closed non-terminal path"
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn content_digest(content: &str) -> String {
|
|
let digest = Sha256::digest(content.as_bytes());
|
|
let mut encoded = String::with_capacity(7 + digest.len() * 2);
|
|
encoded.push_str("sha256:");
|
|
for byte in digest {
|
|
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
|
}
|
|
encoded
|
|
}
|
|
|
|
struct RejectImports;
|
|
|
|
impl ImportLoader for RejectImports {
|
|
fn load(
|
|
&mut self,
|
|
_current_key: Option<&str>,
|
|
specifier: &str,
|
|
) -> decodal::Result<LoadedImport> {
|
|
Err(decodal::Diagnostic::new(
|
|
decodal::DiagnosticKind::Import,
|
|
decodal::Span::default(),
|
|
format!("Flow source imports are not enabled: {specifier}"),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
|
match data {
|
|
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
|
decodal::Data::Int(value) => serde_json::Value::Number(serde_json::Number::from(*value)),
|
|
decodal::Data::Float(value) => serde_json::Number::from_f64(*value)
|
|
.map(serde_json::Value::Number)
|
|
.unwrap_or(serde_json::Value::Null),
|
|
decodal::Data::String(value) => serde_json::Value::String(value.clone()),
|
|
decodal::Data::Array(values) => {
|
|
serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect())
|
|
}
|
|
decodal::Data::Object(fields) => serde_json::Value::Object(
|
|
fields
|
|
.iter()
|
|
.map(|field| (field.name.clone(), decodal_data_to_json(&field.value)))
|
|
.collect(),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn one_diagnostic(
|
|
code: impl Into<String>,
|
|
path: impl Into<String>,
|
|
message: impl Into<String>,
|
|
) -> FlowCompileError {
|
|
FlowCompileError {
|
|
diagnostics: vec![FlowDiagnostic::new(code, path, message)],
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn valid_source() -> &'static str {
|
|
r#"{
|
|
schema_version = 1;
|
|
name = "coder-review";
|
|
initial = "code";
|
|
states = {
|
|
code = {
|
|
instructions = "Implement and validate the requested change.";
|
|
transitions = {
|
|
review = {
|
|
target = "review";
|
|
condition = "Implementation and validation evidence are present.";
|
|
};
|
|
};
|
|
};
|
|
review = {
|
|
instructions = "Read the independent review result.";
|
|
transitions = {
|
|
done = {
|
|
target = "done";
|
|
condition = "The independent reviewer approved the implementation.";
|
|
};
|
|
fix = {
|
|
target = "fix";
|
|
condition = "The independent reviewer requested concrete changes.";
|
|
};
|
|
};
|
|
};
|
|
fix = {
|
|
instructions = "Resolve every open review finding and validate the fixes.";
|
|
transitions = {
|
|
review = {
|
|
target = "review";
|
|
condition = "The requested changes are resolved and ready for re-review.";
|
|
};
|
|
};
|
|
};
|
|
done = {
|
|
instructions = "";
|
|
terminal = true;
|
|
};
|
|
};
|
|
}"#
|
|
}
|
|
|
|
#[test]
|
|
fn compiles_definition_and_injects_cancel_transition() {
|
|
let definition = compile_flow_source(valid_source()).expect("valid Flow");
|
|
assert_eq!(definition.name, "coder-review");
|
|
assert!(definition.content_digest.starts_with("sha256:"));
|
|
let code = definition
|
|
.state(&StateId::new("code").unwrap())
|
|
.expect("code state");
|
|
assert_eq!(code.transitions.len(), 2);
|
|
assert!(
|
|
code.transitions
|
|
.iter()
|
|
.any(|transition| transition.id.as_str() == CANCEL_TRANSITION_ID
|
|
&& transition.synthetic)
|
|
);
|
|
let cancelled = definition
|
|
.state(&StateId(CANCELLED_STATE_ID.to_string()))
|
|
.expect("synthetic cancelled state");
|
|
assert!(cancelled.terminal);
|
|
assert!(cancelled.instructions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_structural_fields() {
|
|
let source = valid_source().replace(
|
|
"schema_version = 1;",
|
|
"schema_version = 1; unexpected = true;",
|
|
);
|
|
let error = compile_flow_source(&source).unwrap_err();
|
|
assert_eq!(error.diagnostics[0].code, "schema_decode");
|
|
assert!(error.diagnostics[0].message.contains("unexpected"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_definition_authored_cancel_target() {
|
|
let source = valid_source().replace("target = \"review\";", "target = \"$cancelled\";");
|
|
let error = compile_flow_source(&source).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| { diagnostic.code == "reserved_transition_target" })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unreachable_state() {
|
|
let source = valid_source().replace(
|
|
"done = {\n instructions = \"\";",
|
|
"unused = { instructions = \"unused\"; terminal = true; };\n done = {\n instructions = \"\";",
|
|
);
|
|
let error = compile_flow_source(&source).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.code == "unreachable_state")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_closed_non_terminal_cycle_even_with_synthetic_cancel() {
|
|
let source = r#"{
|
|
schema_version = 1;
|
|
name = "closed-cycle";
|
|
initial = "a";
|
|
states = {
|
|
a = {
|
|
instructions = "a";
|
|
transitions = { to_b = { target = "b"; condition = "go b"; }; };
|
|
};
|
|
b = {
|
|
instructions = "b";
|
|
transitions = { to_a = { target = "a"; condition = "go a"; }; };
|
|
};
|
|
done = { instructions = ""; terminal = true; };
|
|
};
|
|
}"#;
|
|
let error = compile_flow_source(source).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.code == "terminal_unreachable")
|
|
);
|
|
}
|
|
}
|