use crate::*; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Severity { Error, Warning, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Issue { pub severity: Severity, pub element: Option, pub code: String, pub field: Option, pub message: String, } fn issue( out: &mut Vec, severity: Severity, id: Option, code: &str, field: &str, message: impl Into, ) { out.push(Issue { severity, element: id, code: code.into(), field: (!field.is_empty()).then(|| field.into()), message: message.into(), }); } pub(crate) fn error( out: &mut Vec, id: Id, code: &str, field: &str, message: impl Into, ) { issue(out, Severity::Error, Some(id), code, field, message); } pub(crate) fn warning( out: &mut Vec, id: Id, code: &str, field: &str, message: impl Into, ) { issue(out, Severity::Warning, Some(id), code, field, message); } fn text(out: &mut Vec, id: Id, field: &str, value: &str) { if !valid_text(value) { error( out, id, "invalid_text", field, "Text contains a character forbidden by XML 1.0.", ); } } pub fn valid_text(value: &str) -> bool { value.chars().all(|c|matches!(c,'\t'|'\n'|'\r'|'\u{20}'..='\u{d7ff}'|'\u{e000}'..='\u{fffd}'|'\u{10000}'..='\u{10ffff}')) } pub fn valid_uri_reference(uri: &str) -> bool { if uri.is_empty() || uri .chars() .any(|c| c.is_whitespace() || c.is_control() || c == '\\') { return false; } let bytes = uri.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { if i + 2 >= bytes.len() || !bytes[i + 1].is_ascii_hexdigit() || !bytes[i + 2].is_ascii_hexdigit() { return false; } i += 2; } i += 1; } true } pub fn parent_permits(parent: Kind, child: Kind) -> bool { if child.is_state() { return parent == Kind::Region; } match child { Kind::Entity | Kind::Actor | Kind::Role => parent == Kind::Entity, Kind::Activity => parent == Kind::Activity, Kind::Function => parent == Kind::Function, Kind::Capability => parent == Kind::Capability, Kind::SystemCapability => parent == Kind::SystemCapability, Kind::DataField => matches!(parent, Kind::ExchangeItem | Kind::DataType), Kind::Port => parent.is_performer() || parent.is_work(), Kind::Lifeline => parent == Kind::Scenario, Kind::Occurrence => parent.is_sequence() || parent == Kind::Occurrence, Kind::StateMachine => parent.is_performer(), Kind::Region => parent == Kind::StateMachine || parent.is_stable_state(), _ => false, } } pub fn ancestor_of_kind(p: &Project, id: Id, kind: Kind) -> Option { let mut current = Some(id); let mut seen = BTreeSet::new(); while let Some(id) = current { if !seen.insert(id) { return None; } let e = p.elements.get(&id)?; if e.kind() == kind { return Some(id); } current = e.parent; } None } fn expect_element( p: &Project, out: &mut Vec, owner: Id, reference: Id, field: &str, allowed: impl Fn(Kind) -> bool, ) { match p.elements.get(&reference) { Some(e) if allowed(e.kind()) && p.layer_of(owner) == Some(e.layer()) => {} _ => error( out, owner, "invalid_reference_kind", field, format!("{field} must reference an existing compatible element in the same layer."), ), } } fn optional_element( p: &Project, out: &mut Vec, owner: Id, id: Option, field: &str, allowed: impl Fn(Kind) -> bool, ) { if let Some(id) = id { expect_element(p, out, owner, id, field, allowed) } else { warning( out, owner, "incomplete_reference", field, format!("Choose {field}."), ); } } fn check_ids(out: &mut Vec, owner: Id, field: &str, ids: &[Id]) { let mut seen = BTreeSet::new(); for id in ids { if !seen.insert(id) { error( out, owner, "duplicate_reference", field, format!( "{field} repeats identity {id}; create distinct occurrence objects for repeated execution." ), ); } } } pub fn validate(p: &Project) -> Vec { let mut out = Vec::new(); if p.schema_version != SCHEMA_VERSION { error( &mut out, p.id, "unsupported_schema", "schema_version", format!( "Unsupported schema {} (expected {SCHEMA_VERSION})", p.schema_version ), ); } let mut ids = BTreeSet::new(); let mut aliases = BTreeMap::new(); for (id, alias) in p.identities() { if !ids.insert(id) { error( &mut out, id, "duplicate_identity", "id", "All model objects share one UUID namespace.", ); } if !valid_alias(alias) { error( &mut out, id, "invalid_alias", "alias", format!("Invalid or reserved alias '{alias}'."), ); } if let Some(other) = aliases.insert(alias, id) { error( &mut out, id, "duplicate_alias", "alias", format!("Alias '{alias}' also belongs to {other}."), ); } } text(&mut out, p.id, "name", &p.name); text(&mut out, p.id, "description", &p.description); if p.name.trim().is_empty() { warning( &mut out, p.id, "unnamed", "name", "Give the project a descriptive name.", ); } if p.elements .values() .filter(|e| e.kind() == Kind::System) .count() > 1 { error( &mut out, p.id, "multiple_systems", "elements", "A single SA analysis has at most one system of interest.", ); } if p.elements.values().any(|e| e.layer() == Layer::Sa) && !p.elements.values().any(|e| e.kind() == Kind::System) { warning( &mut out, p.id, "missing_system", "elements", "Identify the SA system of interest.", ); } for (key, e) in &p.elements { if *key != e.id { error( &mut out, e.id, "map_identity", "id", "Element ID does not match its map key.", ); } text(&mut out, e.id, "name", &e.name); text(&mut out, e.id, "description", &e.description); if e.name.trim().is_empty() { warning( &mut out, e.id, "unnamed", "name", "Give this element a name.", ); } if let Some(parent) = e.parent { match p.elements.get(&parent) { Some(owner) if parent != e.id && owner.layer() == e.layer() && parent_permits(owner.kind(), e.kind()) => {} _ => error( &mut out, e.id, "invalid_parent", "parent", "Parent must be a compatible owner in the same layer.", ), } } else if e.kind().is_state() || matches!( e.kind(), Kind::DataField | Kind::Port | Kind::Lifeline | Kind::Occurrence | Kind::Region ) { error( &mut out, e.id, "missing_parent", "parent", "This kind requires a structural owner.", ); } let mut current = e.parent; let mut seen = BTreeSet::from([e.id]); while let Some(id) = current { if !seen.insert(id) { error( &mut out, e.id, "containment_cycle", "parent", "Containment must be acyclic.", ); break; } current = p.elements.get(&id).and_then(|e| e.parent); } for id in crate::references::element_references(e) { if !p.contains(id) { error( &mut out, e.id, "dangling_reference", "body", format!("Reference {id} does not exist."), ); } } if e.kind().is_work() && !p.children(e.id).any(|child| child.kind() == e.kind()) && effective_performer(p, e.id).is_none() { warning( &mut out, e.id, "unallocated_leaf", "allocation", format!("Allocate leaf '{}' to a performer.", e.name), ); } if matches!(e.kind(), Kind::Capability | Kind::SystemCapability) && !p.relations.values().any(|r| { matches!( r.kind(), RelationKind::CapabilityInvolvement | RelationKind::ChainInvolvement ) && r.target() == e.id }) { warning( &mut out, e.id, "unsupported_capability", "involvement", "Connect the capability to involved participants, work, or a process/chain.", ); } if let Some(steps) = e.steps() { check_ids(&mut out, e.id, "steps", steps); for step in steps { match p.elements.get(step) { Some(occ) if occ.kind() == Kind::Occurrence && occ.parent == Some(e.id) => {} _ => error( &mut out, e.id, "invalid_occurrence", "steps", "Ordered steps must reference occurrences owned by this sequence.", ), } } if e.kind().is_sequence() && steps.is_empty() { warning( &mut out, e.id, "empty_sequence", "steps", "Add ordered occurrences.", ); } validate_sequence(p, e, steps, &mut out); } if let Some(shared) = e.shared() { match shared { SharedBody::DataType { .. } | SharedBody::DataField { .. } | SharedBody::Interface { .. } | SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out), SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out), SharedBody::Scenario { viewpoint, context, precondition, postcondition, .. } => { match viewpoint { ScenarioViewpoint::MixedLegacy => warning( &mut out, e.id, "legacy_scenario_viewpoint", "viewpoint", "Choose a scenario viewpoint after reviewing the legacy lifelines and messages.", ), ScenarioViewpoint::OperationalActivity | ScenarioViewpoint::ParticipantInteraction if e.layer() != Layer::Oa => { error( &mut out, e.id, "scenario_viewpoint_layer", "viewpoint", "Operational scenario viewpoints belong to OA.", ) } ScenarioViewpoint::Functional | ScenarioViewpoint::BoundaryInterface if e.layer() != Layer::Sa => { error( &mut out, e.id, "scenario_viewpoint_layer", "viewpoint", "System scenario viewpoints belong to SA.", ) } _ => {} } if let Some(id) = context { expect_element(p, &mut out, e.id, *id, "context", |k| { k.is_sequence() || k.is_work() || k.is_performer() || matches!( k, Kind::Capability | Kind::SystemCapability | Kind::OperationalMission | Kind::Mission ) }); } text(&mut out, e.id, "precondition", precondition); text(&mut out, e.id, "postcondition", postcondition); } SharedBody::Lifeline { represented } => { optional_element(p, &mut out, e.id, *represented, "represented", |k| { k.is_performer() || k.is_work() || k == Kind::Role }); if let Some(element) = represented.and_then(|id| p.elements.get(&id)) && let Some(viewpoint) = e.parent.and_then(|id| scenario_viewpoint(p, id)) && !viewpoint_represents(viewpoint, element.kind()) { error( &mut out, e.id, "scenario_viewpoint_lifeline", "represented", "The represented object must match the scenario's work or participant viewpoint.", ); } } SharedBody::Occurrence { body, precondition, postcondition, } => { text(&mut out, e.id, "precondition", precondition); text(&mut out, e.id, "postcondition", postcondition); validate_occurrence(p, e, body, &mut out); validate_occurrence_context(p, e, body, &mut out); } SharedBody::StateMachine => { if e.parent.is_none() { warning( &mut out, e.id, "machine_owner", "parent", "Assign a state-machine owner.", ); } if !p.children(e.id).any(|c| c.kind() == Kind::Region) { warning( &mut out, e.id, "empty_machine", "body", "Add a region; sibling regions describe concurrent state configurations.", ); } } SharedBody::State { entry, exit, do_activity, .. } => { text(&mut out, e.id, "entry", entry); text(&mut out, e.id, "exit", exit); text(&mut out, e.id, "do_activity", do_activity); if !e.kind().is_stable_state() && (!entry.is_empty() || !exit.is_empty() || !do_activity.is_empty()) { warning( &mut out, e.id, "pseudostate_behavior", "body", "Pseudostate entry/do/exit prose is retained for review; only State/Mode vertices execute state behaviors.", ); } } SharedBody::Event { body } => match body { EventBody::Signal { items } => { check_ids(&mut out, e.id, "items", items); for item in items { expect_element(p, &mut out, e.id, *item, "items", |kind| { kind == Kind::ExchangeItem }); } } EventBody::Time { expression, .. } | EventBody::Change { expression } => { text(&mut out, e.id, "expression", expression); if expression.trim().is_empty() { warning( &mut out, e.id, "missing_event_expression", "expression", "Describe the time instant/duration or change condition for this event.", ); } } }, SharedBody::Constraint { expression, constrained, } => { text(&mut out, e.id, "expression", expression); check_ids(&mut out, e.id, "constrained", constrained); if expression.trim().is_empty() { warning( &mut out, e.id, "empty_constraint", "expression", "Write the constraint expression.", ); } for id in constrained { if p.layer_of(*id) != Some(e.layer()) { error( &mut out, e.id, "constraint_layer", "constrained", "Constraints refer to semantic records in the same layer.", ); } } } _ => {} } } } crate::state_validation::validate_graph(p, &mut out); for (key, r) in &p.relations { if *key != r.id { error( &mut out, r.id, "map_identity", "id", "Relation ID does not match its map key.", ); } text(&mut out, r.id, "name", &r.name); text(&mut out, r.id, "description", &r.description); validate_relation(p, r, &mut out); crate::data::validate_relation(p, r, &mut out); crate::ports::validate_boundary_interface_requirements(p, r, &mut out); validate_relation_context(p, r, &mut out); } crate::data::validate_record_cycles(p, &mut out); for (key, d) in &p.diagrams { if *key != d.id { error( &mut out, d.id, "map_identity", "id", "Diagram ID does not match its map key.", ); } text(&mut out, d.id, "name", &d.name); text(&mut out, d.id, "description", &d.description); if let Some(machine) = d.projection.state_machine() && !p .elements .get(&machine) .is_some_and(|e| e.kind() == Kind::StateMachine && e.layer() == d.layer) { error( &mut out, d.id, "state_diagram_context", "projection", "State diagram context must reference an existing state machine in the diagram layer.", ); } let mut routed_edges = BTreeSet::new(); for (index, route) in d.routes.iter().enumerate() { if !routed_edges.insert(route.edge) { error( &mut out, d.id, "duplicate_edge_route", "routes", "Each relation or trace has at most one route per diagram.", ); } if !p.relations.contains_key(&route.edge) && !p.traces.contains_key(&route.edge) { error( &mut out, d.id, "route_edge", "routes", "Route edge must reference an existing relation or trace.", ); } if let Some(machine) = d.projection.state_machine() && !p.relations.get(&route.edge).is_some_and(|r| { r.kind() == RelationKind::StateTransition && crate::projection::state_machine_contains(p, machine, r.source()) && crate::projection::state_machine_contains(p, machine, r.target()) }) { error( &mut out, d.id, "state_diagram_route", "routes", "State diagram routes belong to transitions in its machine. Remove or move incompatible routes in the same transaction.", ); } if route.waypoints.len() > MAX_ROUTE_POINTS { error( &mut out, d.id, "route_point_limit", "routes", format!("A route supports at most {MAX_ROUTE_POINTS} pinned points."), ); } for (point_index, point) in route.waypoints.iter().enumerate() { for (axis, value) in [("x", point.x), ("y", point.y)] { if !value.is_finite() || value.abs() > MAX_COORDINATE { error( &mut out, d.id, "invalid_route_bounds", "routes", format!( "Route {} point {} {axis} must be finite and bounded.", index + 1, point_index + 1 ), ); } } } if let Some(label) = &route.label { if !label.fraction.is_finite() || !(0. ..=1.).contains(&label.fraction) { error( &mut out, d.id, "invalid_route_fraction", "routes", "Label fraction must be between zero and one.", ); } for (axis, value) in [("x", label.offset.x), ("y", label.offset.y)] { if !value.is_finite() || value.abs() > MAX_COORDINATE { error( &mut out, d.id, "invalid_route_bounds", "routes", format!( "Route {} label offset {axis} must be finite and bounded.", index + 1 ), ); } } } } let mut elements = BTreeSet::new(); for (key, n) in &d.nodes { let mut ports = BTreeSet::new(); for anchor in &n.port_anchors { if !ports.insert(anchor.port) { error( &mut out, n.id, "duplicate_port_anchor", "port_anchors", "Each port has at most one manual anchor per owner placement.", ); } if !p .elements .get(&anchor.port) .is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element)) { error( &mut out, n.id, "port_anchor_owner", "port_anchors", "A manual anchor must reference a direct child port of its owner placement; repair anchors in the same transaction when reparenting.", ); } if !anchor.offset.is_finite() || !(0. ..=1.).contains(&anchor.offset) { error( &mut out, n.id, "invalid_port_offset", "port_anchors", "Port offset must be finite and between zero and one.", ); } } if *key != n.id { error( &mut out, n.id, "map_identity", "id", "Node ID does not match its map key.", ); } if !elements.insert(n.element) { error( &mut out, n.id, "duplicate_placement", "element", "An element appears at most once in each diagram.", ); } match p.elements.get(&n.element) { Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => { } _ => error( &mut out, n.id, "diagram_layer", "element", "Diagram nodes reference existing elements in the selected layer.", ), }; if let Some(machine) = d.projection.state_machine() && !crate::projection::state_machine_contains(p, machine, n.element) { error( &mut out, n.id, "state_diagram_member", "element", "State diagrams contain only their machine, its regions and vertices. Remove or relocate incompatible placements in the same transaction.", ); } for (value, field) in [ (n.position.x, "x"), (n.position.y, "y"), (n.size.width, "width"), (n.size.height, "height"), ] { if !value.is_finite() || value.abs() > MAX_COORDINATE || matches!(field, "width" | "height") && value <= 0. { error( &mut out, n.id, "invalid_bounds", field, "Coordinates must be finite and bounded; width and height must be positive.", ); } } } } for (key, a) in &p.artifact_links { if *key != a.id { error( &mut out, a.id, "map_identity", "id", "Artifact ID does not match its map key.", ); } for (field, value) in [ ("name", &a.name), ("description", &a.description), ("uri", &a.uri), ] { text(&mut out, a.id, field, value); } if let Some(v) = &a.revision { text(&mut out, a.id, "revision", v); } if let Some(v) = &a.fragment { text(&mut out, a.id, "fragment", v); } if !p.contains(a.owner) || a.owner == a.id { error( &mut out, a.id, "artifact_owner", "owner", "Artifact owner must be another existing model object.", ); } if !valid_uri_reference(&a.uri) { error( &mut out, a.id, "invalid_uri", "uri", "Use a nonempty URI reference without whitespace, backslashes, or invalid percent escapes.", ); } } for (key, t) in &p.traces { if *key != t.id { error( &mut out, t.id, "map_identity", "id", "Trace ID does not match its map key.", ); } text(&mut out, t.id, "name", &t.name); text(&mut out, t.id, "description", &t.description); if p.layer_of(t.oa) != Some(Layer::Oa) || p.layer_of(t.sa) != Some(Layer::Sa) { error( &mut out, t.id, "trace_layers", "endpoints", "Traces have a live OA endpoint and a live SA endpoint in canonical order.", ); } else if !trace_permits(p, t) { error( &mut out, t.id, "trace_kinds", "kind", "Trace kind is incompatible with its endpoint kinds.", ); } if let Some(b) = &t.baseline { for props in [&b.oa, &b.sa] { text(&mut out, t.id, "baseline.name", &props.name); text(&mut out, t.id, "baseline.description", &props.description); } if let Some(semantic) = &b.semantic && let Err(errors) = crate::semantic::validate_semantic_baseline(semantic) { for message in errors { error( &mut out, t.id, "semantic_baseline", "baseline.semantic", message, ); } } } match crate::transitions::trace_drift(p, t) { crate::transitions::Drift::Unchanged => {} crate::transitions::Drift::Unreviewed => warning( &mut out, t.id, "trace_unreviewed", "baseline", "Review the linked OA/SA name and description values before propagating updates.", ), drift => warning( &mut out, t.id, "trace_drift", "baseline", format!( "Linked descriptions changed since review ({drift:?}); preview the transition to reconcile them." ), ), } if p.traces.values().any(|other| { other.id != t.id && other.oa == t.oa && other.sa == t.sa && other.kind == t.kind }) { error( &mut out, t.id, "duplicate_trace", "endpoints", "An identical trace mapping already exists.", ); } } out } fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec) { if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { return; } let links: Vec<_> = p .relations .values() .filter(|relation| { relation.kind() == RelationKind::SequenceLink && p.elements.get(&relation.source()).and_then(|e| e.parent) == Some(e.id) && p.elements.get(&relation.target()).and_then(|e| e.parent) == Some(e.id) }) .collect(); let explicit_topology = !links.is_empty() || steps.iter().any(|id| { p.elements.get(id).is_some_and(|step| { matches!( step.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Action { .. } | OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. }, .. }) ) }) }); if explicit_topology { validate_topology(p, e, steps, &links, out); return; } // Version 2 exchange-only lists preserve their established linear meaning. let mut previous: Option<(usize, &Relation)> = None; for (index, id) in steps.iter().enumerate() { let exchange = p .elements .get(id) .and_then(|o| match o.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange, .. }, .. }) => *exchange, _ => None, }) .and_then(|r| p.relations.get(&r)); if let Some(r) = exchange { if let Some((previous_index, prev)) = previous && prev.target() != r.source() { warning( out, e.id, "sequence_gap", "steps", format!( "Process '{}': disconnected steps {} and {}. '{}' ends at '{}'; '{}' starts at '{}'.", e.name, previous_index + 1, index + 1, prev.name, p.named(prev.target()), r.name, p.named(r.source()) ), ); } previous = Some((index, r)); } else { warning( out, e.id, "incomplete_step", "steps", format!("Select an exchange for step {}.", index + 1), ); } } } fn reachable(starts: &[Id], graph: &BTreeMap>) -> BTreeSet { let mut pending = starts.to_vec(); let mut seen = BTreeSet::new(); while let Some(id) = pending.pop() { if seen.insert(id) { pending.extend(graph.get(&id).into_iter().flatten().copied()); } } seen } fn validate_topology( p: &Project, sequence: &Element, steps: &[Id], links: &[&Relation], out: &mut Vec, ) { let mut outgoing: BTreeMap> = BTreeMap::new(); let mut incoming: BTreeMap> = BTreeMap::new(); for link in links { outgoing .entry(link.source()) .or_default() .push(link.target()); incoming .entry(link.target()) .or_default() .push(link.source()); } let count = |graph: &BTreeMap>, id| graph.get(&id).map_or(0, Vec::len); let starts: Vec<_> = steps .iter() .copied() .filter(|id| count(&incoming, *id) == 0) .collect(); let ends: Vec<_> = steps .iter() .copied() .filter(|id| count(&outgoing, *id) == 0) .collect(); for (code, label, nodes) in [ ("sequence_starts", "start", &starts), ("sequence_ends", "end", &ends), ] { if nodes.len() != 1 { warning( out, sequence.id, code, "steps", format!( "Process/chain '{}' has {} {} contributions; connect a clear entry/exit or review the intended independent paths.", sequence.name, nodes.len(), label ), ); } } let from_start = reachable(&starts, &outgoing); let to_end = reachable(&ends, &incoming); for id in steps { let Some(step) = p.elements.get(id) else { continue; }; let before = count(&incoming, *id); let after = count(&outgoing, *id); if steps.len() > 1 && before == 0 && after == 0 { warning( out, *id, "isolated_contribution", "parent", "Connect this contribution to its process/chain path.", ); } if !from_start.contains(id) { warning( out, *id, "unreachable_contribution", "parent", "This contribution cannot be reached from a process/chain entry.", ); } if !to_end.contains(id) { warning( out, *id, "nonterminating_contribution", "parent", "No process/chain exit is reachable from this contribution; review the intended loop.", ); } let control = match step.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Control { kind }, .. }) => Some(*kind), _ => None, }; if let Some(kind) = control { let expected = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2, }; if !expected { let rule = match kind { ControlKind::Choice | ControlKind::Fork => { "one incoming and at least two outgoing links" } ControlKind::Merge | ControlKind::Join => { "at least two incoming and one outgoing link" } ControlKind::Loop => { "at least one incoming and two outgoing links (repeat and exit)" } }; warning( out, *id, "control_cardinality", "body.kind", format!( "{kind:?} needs {rule}; currently {before} incoming and {after} outgoing." ), ); } if matches!(kind, ControlKind::Choice | ControlKind::Loop) { for link in links.iter().filter(|link| link.source() == *id) { if matches!(&link.body, RelationBody::SequenceLink { guard, .. } if guard.trim().is_empty()) { warning( out, link.id, "missing_sequence_guard", "guard", "Describe the choice/loop condition for this path, or use an explicit 'else' guard.", ); } } } } else if before > 1 || after > 1 { warning( out, *id, "ambiguous_sequence_branch", "parent", "Use an explicit choice/merge or fork/join control to distinguish alternative from parallel paths.", ); } } } fn sequence_reference_path(p: &Project, from: Id, to: Id) -> bool { let mut graph: BTreeMap> = BTreeMap::new(); for element in p.elements.values() { if let Some(owner) = element.parent && let Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target), }, .. }) = element.shared() { graph.entry(owner).or_default().push(*target); } } reachable(&[from], &graph).contains(&to) } fn scenario_viewpoint(p: &Project, scenario: Id) -> Option { match p.elements.get(&scenario)?.shared()? { SharedBody::Scenario { viewpoint, .. } => Some(*viewpoint), _ => None, } } /// Represented element families allowed by a declared scenario viewpoint. /// Shared by validation and graphical lifeline selection; layers are checked separately. pub fn viewpoint_represents(viewpoint: ScenarioViewpoint, kind: Kind) -> bool { match viewpoint { ScenarioViewpoint::OperationalActivity => kind == Kind::Activity, ScenarioViewpoint::Functional => kind == Kind::Function, ScenarioViewpoint::ParticipantInteraction => { matches!(kind, Kind::Entity | Kind::Actor | Kind::Role) } ScenarioViewpoint::BoundaryInterface => matches!(kind, Kind::System | Kind::SystemActor), ScenarioViewpoint::MixedLegacy => { kind.is_work() || kind.is_performer() || kind == Kind::Role } } } fn represented(p: &Project, line: Id) -> Option { match p.elements.get(&line)?.shared()? { SharedBody::Lifeline { represented } => *represented, _ => None, } } /// Whether an explicit semantic representation agrees with an endpoint. /// None means a missing reference or an unresolved concrete performer; it is not /// permission to infer one participant from several role realizations. pub fn semantic_representation_compatible( p: &Project, representation: Id, semantic: Id, ) -> Option { let represented = p.elements.get(&representation)?; let endpoint = p.elements.get(&semantic)?; if represented.layer() != endpoint.layer() { return Some(false); } if representation == semantic { return Some(true); } let realizes = |participant, role| { p.relations.values().any(|r| { r.kind() == RelationKind::RoleRealization && r.source() == participant && r.target() == role }) }; if endpoint.kind().is_work() { if represented.kind() == Kind::Role { return Some(p.relations.values().any(|r| { r.kind() == RelationKind::RoleAssignment && r.source() == representation && r.target() == semantic })); } if !represented.kind().is_performer() { return Some(false); } return effective_performer(p, semantic).map(|performer| performer == representation); } if endpoint.kind() == Kind::Port { return endpoint.parent.map(|owner| { owner == representation || (represented.kind() == Kind::Role && realizes(owner, representation)) }); } Some( (represented.kind() == Kind::Role && realizes(semantic, representation)) || (endpoint.kind() == Kind::Role && realizes(representation, semantic)), ) } /// Compatibility shared by scenario validation and graphical lifeline pickers. pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option { semantic_representation_compatible(p, represented(p, line)?, endpoint) } /// Effective semantic direction, including a Reply that reuses its Call exchange. /// An incomplete reply correlation and a local timer have no inferred endpoints. pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> { let OccurrenceBody::Exchange { exchange: Some(exchange), message_kind, reply_to, .. } = body else { return None; }; if matches!(message_kind, MessageKind::Timer { .. }) { return None; } let relation = p.relations.get(exchange)?; if message_kind == &MessageKind::Reply { let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, exchange: Some(call_exchange), .. }, .. }) = p.elements.get(&(*reply_to)?).and_then(Element::shared) else { return None; }; if call_exchange == exchange { return Some((relation.target(), relation.source())); } } Some(relation.body.endpoints()) } /// Only report order guaranteed by a common sequence or an enclosing branch path. /// A call in one alternative does not become an unconditional call after the fragment. fn guaranteed_before(p: &Project, before: Id, after: Id) -> bool { let mut current = after; let mut seen = BTreeSet::new(); while seen.insert(current) { let Some(parent) = p .elements .get(¤t) .and_then(|e| e.parent) .and_then(|id| p.elements.get(&id)) else { break; }; if let Some(steps) = parent.steps() && let (Some(a), Some(b)) = ( steps.iter().position(|id| *id == before), steps.iter().position(|id| *id == current), ) { return a < b; } current = parent.id; } false } /// Structural co-execution/order, distinct from call dominance. A conditional /// branch can execute before a following sibling; distinct ALT operands cannot /// execute together. Parallel operands can execute in either order. fn possibly_before(p: &Project, before: Id, after: Id) -> bool { let path = |id: Id| { let mut path = vec![]; let mut current = Some(id); let mut seen = BTreeSet::new(); while let Some(id) = current { if !seen.insert(id) { return vec![]; } let Some(element) = p.elements.get(&id) else { return vec![]; }; path.push(id); if element.kind() == Kind::Scenario { path.reverse(); return path; } current = element.parent; } vec![] }; let a = path(before); let b = path(after); let common = a.iter().zip(&b).take_while(|(a, b)| a == b).count(); if common == 0 || common == a.len() || common == b.len() { return false; } let parent = &p.elements[&a[common - 1]]; if let Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { operator, branches }, .. }) = parent.shared() { return match operator { FragmentOperator::Alternative | FragmentOperator::Optional => false, FragmentOperator::Parallel => true, FragmentOperator::Loop => { matches!((branches.iter().position(|id|*id==a[common]),branches.iter().position(|id|*id==b[common])),(Some(a),Some(b))if a BTreeSet { let mut loops = BTreeSet::new(); let mut current = p.elements.get(&occurrence).and_then(|e| e.parent); let mut seen = BTreeSet::new(); while let Some(id) = current { if !seen.insert(id) { break; } let Some(element) = p.elements.get(&id) else { break; }; if matches!( element.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { operator: FragmentOperator::Loop, .. }, .. }) ) { loops.insert(id); } if element.kind() == Kind::Scenario { break; } current = element.parent; } loops } /// Fields carried by an exchange, including fields of nested named records. pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet { crate::data::carried_field_paths(p, relation) .into_iter() .map(|f| f.field) .collect() } fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool { match body { OccurrenceBody::Exchange { sender, receiver, .. } => *sender == Some(line) || *receiver == Some(line), OccurrenceBody::Action { lifeline, .. } | OccurrenceBody::State { lifeline, .. } => { *lifeline == Some(line) } OccurrenceBody::ScenarioUse { bindings, .. } => bindings.iter().any(|b| b.local == line), _ => false, } } fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec) { let OccurrenceBody::Exchange { message_kind, exchange, sender, receiver, reply_to, values, } = body else { return; }; let scenario = ancestor_of_kind(p, e.id, Kind::Scenario); if scenario.is_none() && (message_kind != &MessageKind::Asynchronous || reply_to.is_some()) { error( out, e.id, "message_context", "message_kind", "Call/reply, lifecycle and timer messages belong to a scenario, not a process/chain exchange contribution.", ); } if let Some(viewpoint) = scenario.and_then(|id| scenario_viewpoint(p, id)) && let Some(relation) = exchange.and_then(|id| p.relations.get(&id)) { let permitted = match viewpoint { ScenarioViewpoint::OperationalActivity => relation.kind() == RelationKind::Interaction, ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange, ScenarioViewpoint::ParticipantInteraction => matches!( relation.kind(), RelationKind::Interaction | RelationKind::BoundaryExchange ), ScenarioViewpoint::BoundaryInterface => matches!( relation.kind(), RelationKind::FunctionalExchange | RelationKind::BoundaryExchange ), ScenarioViewpoint::MixedLegacy => true, }; if !permitted { error( out, e.id, "scenario_viewpoint_exchange", "exchange", "Choose an exchange compatible with this scenario viewpoint.", ); } } if let MessageKind::Timer { expression } = message_kind { text(out, e.id, "message_kind.expression", expression); if expression.trim().is_empty() { warning( out, e.id, "missing_timer_expression", "message_kind.expression", "Describe the timer duration or deadline expression.", ); } if exchange.is_some() || !values.is_empty() || receiver.is_some_and(|line| Some(line) != *sender) { error( out, e.id, "timer_contract", "body", "A timer is local to its sender lifeline, has no global exchange or carried message values, and its receiver is absent or the same lifeline.", ); } } if message_kind == &MessageKind::Reply { if let Some(call) = reply_to { match p.elements.get(call).and_then(Element::shared) { Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, exchange: call_exchange, sender: call_sender, receiver: call_receiver, .. }, .. }) if scenario.is_some() && ancestor_of_kind(p, *call, Kind::Scenario) == scenario => { if !guaranteed_before(p, *call, e.id) { error( out, e.id, "reply_order", "reply_to", "The call must precede its reply on the same guaranteed branch path.", ); } let call_loops = enclosing_loops(p, *call); for repeated in enclosing_loops(p, e.id).difference(&call_loops) { warning( out, e.id, "reply_repetition", "reply_to", format!( "Loop '{}' can repeat this reply without repeating its correlated call. Move the call into the same repeating scope or review an explicit at-most-once constraint.", p.named(*repeated) ), ); } if sender.zip(*call_receiver).is_some_and(|(a, b)| a != b) || receiver.zip(*call_sender).is_some_and(|(a, b)| a != b) { error( out, e.id, "reply_lifelines", "reply_to", "Reply lifelines must reverse the correlated call's sender and receiver.", ); } if let (Some(response), Some(request)) = ( exchange.and_then(|id| p.relations.get(&id)), call_exchange.and_then(|id| p.relations.get(&id)), ) && response.id != request.id && response.body.endpoints() != (request.target(), request.source()) { error( out, e.id, "reply_exchange", "exchange", "A reply reuses the call exchange in reverse, or references an explicit reverse-direction exchange.", ); } if p.elements.values().any(|other| other.id != e.id && possibly_before(p,other.id,e.id) && matches!(other.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {message_kind:MessageKind::Reply,reply_to:Some(previous),..},..}) if previous == call)) { error(out,e.id,"duplicate_reply","reply_to","Another reply to this call can execute earlier on the same path, including a conditional or parallel branch."); } } _ => error( out, e.id, "reply_call", "reply_to", "A reply references a synchronous call in the same scenario.", ), } } else { warning( out, e.id, "missing_reply_call", "reply_to", "Choose the synchronous call answered by this reply.", ); } } else if reply_to.is_some() { error( out, e.id, "unexpected_reply_reference", "reply_to", "Only a Reply message correlates to a call.", ); } if matches!(message_kind, MessageKind::Create | MessageKind::Delete) && let Some(line) = receiver { for other in p.elements.values().filter(|other| { other.id != e.id && ancestor_of_kind(p, other.id, Kind::Scenario) == scenario }) { if let Some(SharedBody::Occurrence { body, .. }) = other.shared() && occurrence_uses_lifeline(body, *line) && (if message_kind == &MessageKind::Create { possibly_before(p, other.id, e.id) } else { possibly_before(p, e.id, other.id) }) { error( out, e.id, "lifeline_lifetime", "receiver", if message_kind == &MessageKind::Create { "The created lifeline can already be used earlier on this path, including a conditional or parallel branch." } else { "The deleted lifeline can be used later on this path, including a conditional or parallel branch." }, ); } } if !enclosing_loops(p, e.id).is_empty() { let complement = if message_kind == &MessageKind::Create { MessageKind::Delete } else { MessageKind::Create }; let paired=p.elements.values().any(|other| other.id!=e.id && other.parent==e.parent && matches!(other.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{message_kind,receiver:Some(target),..},..})if message_kind==&complement && target==line) && (if message_kind==&MessageKind::Create {guaranteed_before(p,e.id,other.id)} else {guaranteed_before(p,other.id,e.id)})); if !paired { warning( out, e.id, "lifeline_repetition", "receiver", if message_kind == &MessageKind::Create { "This loop can recreate an existing lifeline. Pair creation with a later deletion in the same operand, or review the lifetime across iterations." } else { "This loop can use or delete an already destroyed lifeline on its next iteration. Pair deletion with an earlier creation in the same operand, or review the lifetime across iterations." }, ); } } } let relation = exchange.and_then(|id| p.relations.get(&id)); if message_kind == &MessageKind::Call && relation.is_some_and(|r| { !r.body .items() .iter() .any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation)) }) { warning( out, e.id, "call_without_operation", "exchange", "This generic call carries no Operation item; define an operation contract when appropriate.", ); } let mut fields = BTreeSet::new(); for value in values { expect_element(p, out, e.id, value.field, "values.field", |kind| { kind == Kind::DataField }); for hop in &value.path { expect_element(p, out, e.id, *hop, "values.path", |kind| { kind == Kind::DataField }); } text(out, e.id, "values.expression", &value.expression); if !fields.insert((&value.path, value.field)) { error( out, e.id, "duplicate_message_value", "values", "Each qualified carried field has at most one contextual value annotation.", ); } if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH { error( out, e.id, "message_value_path_limit", "values.path", "The value path exceeds the bounded authoring depth.", ); } if let Some(r) = relation { if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) { if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) { let permitted = match message_kind { MessageKind::Call => matches!( path.direction, ParameterDirection::Unspecified | ParameterDirection::In | ParameterDirection::InOut ), MessageKind::Reply => matches!( path.direction, ParameterDirection::Unspecified | ParameterDirection::Out | ParameterDirection::InOut | ParameterDirection::Return ), _ => true, }; if !permitted { error( out, e.id, "message_parameter_phase", "values.path", "Call values address In/InOut parameters; replies address Out/InOut/Return parameters.", ); } if path.direction == ParameterDirection::Unspecified { warning( out, e.id, "message_parameter_direction", "values.path", "The containing operation parameter has no direction yet.", ); } } } else { let legacy = value .path .is_empty() .then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok()) .flatten(); if matches!( legacy, Some( crate::data::LegacyFieldPathResolution::Unique(_) | crate::data::LegacyFieldPathResolution::Ambiguous ) ) { warning( out, e.id, if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) { "message_value_ambiguous_path" } else { "message_value_missing_path" }, "values.path", "This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.", ); } else { error( out, e.id, "message_value_field", "values.path", "The qualified field must be carried by the exchange, following Value record fields only.", ); } } } if value.expression.trim().is_empty() { warning( out, e.id, "missing_message_value", "values.expression", "Enter the contextual value expression for this field.", ); } } } fn validate_scenario_use( p: &Project, e: &Element, target: Option, bindings: &[LifelineBinding], out: &mut Vec, ) { let local = ancestor_of_kind(p, e.id, Kind::Scenario); if local.is_none() { error( out, e.id, "scenario_use_owner", "parent", "A scenario use belongs to a scenario or one of its branches.", ); } optional_element(p, out, e.id, target, "scenario", |kind| { kind == Kind::Scenario }); if let (Some(local), Some(target)) = (local, target) { let mut graph: BTreeMap> = BTreeMap::new(); for use_element in p.elements.values() { if let Some(owner) = ancestor_of_kind(p, use_element.id, Kind::Scenario) && let Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: Some(referenced), .. }, .. }) = use_element.shared() { graph.entry(owner).or_default().push(*referenced); } } if reachable(&[target], &graph).contains(&local) { error( out, e.id, "recursive_scenario_use", "scenario", "A scenario cannot directly or transitively reuse itself; use an explicit loop fragment for repetition.", ); } if let (Some(a), Some(b)) = (scenario_viewpoint(p, local), scenario_viewpoint(p, target)) && a != b && a != ScenarioViewpoint::MixedLegacy && b != ScenarioViewpoint::MixedLegacy { error( out, e.id, "scenario_use_viewpoint", "scenario", "A reused scenario must have the same viewpoint as its caller.", ); } } let mut referenced_lines = BTreeSet::new(); let mut local_lines = BTreeSet::new(); for binding in bindings { for (line, owner, field) in [ (binding.referenced, target, "bindings.referenced"), (binding.local, local, "bindings.local"), ] { expect_element(p, out, e.id, line, field, |kind| kind == Kind::Lifeline); if owner.is_none() || p.elements.get(&line).and_then(|e| e.parent) != owner { error( out, e.id, "scenario_binding_context", field, "Bind a lifeline from the referenced scenario to one in the local scenario.", ); } } if !referenced_lines.insert(binding.referenced) || !local_lines.insert(binding.local) { error( out, e.id, "duplicate_lifeline_binding", "bindings", "Scenario-use lifeline bindings are one-to-one.", ); } if let (Some(a), Some(b)) = ( represented(p, binding.referenced), represented(p, binding.local), ) && semantic_representation_compatible(p, b, a) == Some(false) { error( out, e.id, "scenario_binding_identity", "bindings", "Bound lifelines represent the same semantic object, or an explicitly realized operational role.", ); } } if let Some(target) = target { for line in p.children(target).filter(|e| e.kind() == Kind::Lifeline) { if !referenced_lines.contains(&line.id) { warning( out, e.id, "missing_lifeline_binding", "bindings", format!( "Bind referenced lifeline '{}' to its local role.", line.name ), ); } } } } fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec) { let scenario = ancestor_of_kind(p, e.id, Kind::Scenario); let sequence = e .parent .and_then(|id| p.elements.get(&id)) .filter(|owner| matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)); let lifeline = |out: &mut Vec, id: Option, field: &str| { if let Some(id) = id { expect_element(p, out, e.id, id, field, |k| k == Kind::Lifeline); if p.elements.get(&id).and_then(|l| l.parent) != scenario { error( out, e.id, "lifeline_context", field, "Lifeline must belong to the occurrence's scenario.", ); } } else if scenario.is_some() { warning( out, e.id, "missing_lifeline", field, format!("Choose {field}."), ); } }; match body { OccurrenceBody::Exchange { exchange, sender, receiver, message_kind, .. } => { if let Some(id) = exchange { match p.relations.get(id) { Some(r) if p.layer_of(*id) == Some(e.layer()) && matches!( r.kind(), RelationKind::Interaction | RelationKind::FunctionalExchange | RelationKind::BoundaryExchange ) => {} _ => error( out, e.id, "occurrence_exchange", "exchange", "Occurrence must reference an exchange in the same layer.", ), } } else if !matches!(message_kind, MessageKind::Timer { .. }) { warning( out, e.id, "missing_exchange", "exchange", "Choose an exchange.", ); } lifeline(out, *sender, "sender"); if !matches!(message_kind, MessageKind::Timer { .. }) || receiver.is_some() { lifeline(out, *receiver, "receiver"); } validate_message(p, e, body, out); if sequence.is_some() { if sender.is_some() || receiver.is_some() { error( out, e.id, "process_lifeline", "body", "Process/chain exchange contributions do not use scenario lifelines.", ); } if let Some(relation) = exchange.and_then(|id| p.relations.get(&id)) && relation.kind() != if e.layer() == Layer::Oa { RelationKind::Interaction } else { RelationKind::FunctionalExchange } { error( out, e.id, "process_exchange", "exchange", "Process/chain contributions reference a work exchange, not a boundary exchange.", ); } } } OccurrenceBody::Action { action, lifeline: line, } => { optional_element(p, out, e.id, *action, "action", Kind::is_work); lifeline(out, *line, "lifeline"); if sequence.is_some() && line.is_some() { error( out, e.id, "process_lifeline", "lifeline", "A process/chain work involvement does not use a scenario lifeline.", ); } } OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. } => { if let Some(owner) = sequence { if let OccurrenceBody::SequenceReference { sequence } = body { optional_element(p, out, e.id, *sequence, "sequence", |kind| { kind == owner.kind() }); if let Some(target) = sequence && sequence_reference_path(p, *target, owner.id) { error( out, e.id, "recursive_sequence_reference", "sequence", "A process/chain cannot directly or transitively reuse itself.", ); } } } else { error( out, e.id, "sequence_contribution_owner", "parent", "Controls and reusable sequence contributions belong directly to a process or functional chain.", ); } } OccurrenceBody::State { state, lifeline: line, } => { optional_element(p, out, e.id, *state, "state", |k| { matches!(k, Kind::State | Kind::Mode) }); lifeline(out, *line, "lifeline"); } OccurrenceBody::ScenarioUse { scenario, bindings } => { validate_scenario_use(p, e, *scenario, bindings, out) } OccurrenceBody::Fragment { branches, .. } => { check_ids(out, e.id, "branches", branches); for id in branches { match p.elements.get(id) { Some(b) if b.parent == Some(e.id) && matches!( b.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) ) => {} _ => error( out, e.id, "fragment_branch", "branches", "Fragments contain branch occurrences they own.", ), } } } OccurrenceBody::Branch { guard, .. } => { text(out, e.id, "guard", guard); if !e .parent .and_then(|id| p.elements.get(&id)) .is_some_and(|p| { matches!( p.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { .. }, .. }) ) }) { error( out, e.id, "branch_owner", "parent", "A branch belongs to a fragment occurrence.", ); } } } if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id)) && matches!(parent.kind(), Kind::Process | Kind::FunctionalChain) && !matches!( body, OccurrenceBody::Exchange { .. } | OccurrenceBody::Action { .. } | OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. } ) { error( out, e.id, "process_occurrence", "body", "Processes and functional chains contain work, exchange, control, or reusable sequence contributions.", ); } } /// Iterative reachability keeps cyclic or deeply nested capability drafts safe. /// Inclusion and specialization are separate graphs, not containment edges. fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bool { let mut pending = vec![from]; let mut seen = BTreeSet::new(); while let Some(current) = pending.pop() { if current == to { return true; } if seen.insert(current) { pending.extend( p.relations .values() .filter(|r| r.kind() == kind && r.source() == current) .map(Relation::target), ); } } false } fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { let (source, target) = r.body.endpoints(); for id in crate::references::relation_references(r) { // Most relation references name elements. Avoid constructing all model // identities per endpoint while retaining the general lookup semantics // for other record kinds and malformed map keys. if !p.elements.get(&id).is_some_and(|element| element.id == id) && !p.contains(id) { error( out, r.id, "dangling_reference", "body", format!("Reference {id} does not exist."), ); } } if let RelationBody::ExchangeAllocation { .. } = r.body { match (p.relations.get(&source), p.relations.get(&target)) { (Some(a), Some(b)) if a.kind() == RelationKind::BoundaryExchange && matches!( b.kind(), RelationKind::FunctionalExchange | RelationKind::Interaction ) && p.layer_of(source) == p.layer_of(target) => {} _ => error( out, r.id, "exchange_allocation", "endpoints", "Exchange allocation relates a boundary exchange to a work exchange in the same layer.", ), }; validate_exchange_allocation(p, r, out); return; } let (Some(a), Some(b)) = (p.elements.get(&source), p.elements.get(&target)) else { error( out, r.id, "invalid_endpoints", "endpoints", "Relationship endpoints must be existing elements.", ); return; }; if a.layer() != b.layer() || !r.kind().permits(a.kind(), b.kind()) || (a.id == b.id && !matches!( r.kind(), RelationKind::StateTransition | RelationKind::DataAssociation )) { error( out, r.id, "invalid_endpoints", "endpoints", format!("Invalid {}: {} in one layer.", r.kind(), r.kind().hint()), ); } if let RelationBody::SequenceLink { guard, .. } = &r.body { text(out, r.id, "guard", guard); let owner = a.parent.and_then(|id| p.elements.get(&id)); if a.parent != b.parent || !owner .is_some_and(|owner| matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)) { error( out, r.id, "sequence_link_context", "endpoints", "Sequence links connect contributions directly owned by the same process or functional chain.", ); } let work = |element: &Element| match element.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Action { action, .. }, .. }) => *action, _ => None, }; let exchange = |element: &Element| match element.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange, .. }, .. }) => exchange.and_then(|id| p.relations.get(&id)), _ => None, }; let mismatch = match (work(a), exchange(a), work(b), exchange(b)) { (Some(work), _, _, Some(exchange)) => work != exchange.source(), (_, Some(exchange), Some(work), _) => exchange.target() != work, (_, Some(previous), _, Some(next)) => previous.target() != next.source(), _ => false, }; if mismatch { error( out, r.id, "sequence_exchange_endpoint", "endpoints", "Adjacent work/exchange contributions must agree with the referenced exchange's source and target work.", ); } } if let RelationBody::CapabilityExtend { condition, .. } = &r.body { text(out, r.id, "condition", condition); if condition.trim().is_empty() { warning( out, r.id, "missing_extension_condition", "condition", "Describe when the extending capability adds behavior to the extended capability.", ); } } if matches!( r.kind(), RelationKind::CapabilityInclude | RelationKind::CapabilityGeneralization ) && relation_path_exists(p, r.kind(), target, source) { let (code, message) = if r.kind() == RelationKind::CapabilityInclude { ( "capability_include_cycle", "Capability inclusion must be acyclic; a capability cannot transitively include itself.", ) } else { ( "capability_generalization_cycle", "Capability specialization must be acyclic; a capability cannot be its own ancestor.", ) }; error(out, r.id, code, "target", message); } if r.kind() == RelationKind::Allocation { if p.children(b.id).any(|child| child.kind() == b.kind()) { error( out, r.id, "allocation_leaf", "target", "Allocate leaf activities/functions; decomposition is independent of performer allocation.", ); } if p.relations .values() .filter(|other| other.kind() == RelationKind::Allocation && other.target() == target) .count() > 1 { error( out, r.id, "multiple_performers", "target", "A leaf activity/function has at most one direct performer.", ); } } if r.kind() == RelationKind::RoleAssignment { if p.children(b.id).any(|child| child.kind() == b.kind()) { error( out, r.id, "role_leaf", "target", "Assign roles to leaf activities.", ); } let performers: BTreeSet<_> = p .relations .values() .filter(|rel| rel.kind() == RelationKind::RoleRealization && rel.target() == source) .map(Relation::source) .collect(); if let Some(allocated) = p .relations .values() .find(|rel| rel.kind() == RelationKind::Allocation && rel.target() == target) && !performers.is_empty() && !performers.contains(&allocated.source()) { warning( out, r.id, "role_allocation_conflict", "target", "Direct performer does not realize the assigned role.", ); } } check_ids(out, r.id, "items", r.body.items()); for item in r.body.items() { expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem); } crate::ports::validate_port_relation(p, r, out); if let RelationBody::StateTransition { trigger, guard, effect, triggers, effects, .. } = &r.body { text(out, r.id, "trigger", trigger); text(out, r.id, "guard", guard); text(out, r.id, "effect", effect); check_ids(out, r.id, "triggers", triggers); check_ids(out, r.id, "effects", effects); for id in triggers { expect_element(p, out, r.id, *id, "triggers", |kind| { kind == Kind::Event || kind.is_work() }); } for id in effects { expect_element(p, out, r.id, *id, "effects", Kind::is_work); } if triggers.is_empty() && !trigger.trim().is_empty() { warning( out, r.id, "unresolved_trigger", "trigger", "This transition retains descriptive triggering, not completion. Choose a typed Event or work trigger to resolve it.", ); } crate::state_validation::validate_transition(p, r, a, b, out); } } pub fn trace_permits(p: &Project, t: &Trace) -> bool { if t.kind == TraceKind::Manual { return p.properties(t.oa).is_some() && p.properties(t.sa).is_some(); } match (p.elements.get(&t.oa), p.elements.get(&t.sa)) { (Some(a), Some(b)) => match t.kind { TraceKind::Refinement => a.kind() == Kind::Activity && b.kind() == Kind::Function, TraceKind::Participant => { matches!(a.kind(), Kind::Entity | Kind::Actor) && matches!(b.kind(), Kind::System | Kind::SystemActor) } TraceKind::Capability => { a.kind() == Kind::Capability && b.kind() == Kind::SystemCapability } TraceKind::Mission => a.kind() == Kind::OperationalMission && b.kind() == Kind::Mission, TraceKind::Sequence => { (a.kind() == Kind::Process && b.kind() == Kind::FunctionalChain) || (a.kind() == Kind::Occurrence && b.kind() == Kind::Occurrence && a.parent .and_then(|id| p.elements.get(&id)) .is_some_and(|owner| owner.kind() == Kind::Process) && b.parent .and_then(|id| p.elements.get(&id)) .is_some_and(|owner| owner.kind() == Kind::FunctionalChain)) } TraceKind::Item => { matches!( a.kind(), Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port ) && a.kind() == b.kind() } TraceKind::Scenario => { matches!(a.kind(), Kind::Scenario | Kind::Lifeline | Kind::Occurrence) && a.kind() == b.kind() } TraceKind::State => { (a.kind().is_state() || matches!(a.kind(), Kind::StateMachine | Kind::Region | Kind::Event)) && a.kind() == b.kind() } _ => false, }, _ => { matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange) } } } /// Parent choices for a newly created object; editing also validates cycles. pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option) -> bool { if !child_kind.supports(layer) { return false; } match parent { None => { !child_kind.is_state() && !matches!( child_kind, Kind::DataField | Kind::Port | Kind::Lifeline | Kind::Occurrence | Kind::Region ) } Some(id) => p.elements.get(&id).is_some_and(|owner| { owner.layer() == layer && parent_permits(owner.kind(), child_kind) && match owner.shared() { Some(SharedBody::DataType { definition, .. }) if child_kind == Kind::DataField => { matches!(definition, DataTypeDefinition::Record { .. }) } Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => { matches!( body, OccurrenceBody::Fragment { .. } | OccurrenceBody::Branch { .. } ) } _ => true, } }), } } /// Sources for new interface roles; preserved legacy associations remain readable. pub fn can_create_interface_role(p: &Project, source: Id) -> bool { p.elements.get(&source).is_some_and(|e| { e.kind().is_performer() || matches!( crate::ports::port_contract(e), Some(PortContract::Component { .. }) ) }) } /// Feasibility of creating a relationship with the default authoring contract. /// An FE builder creates its typed ports; validating an exact unbound FE here /// would incorrectly hide every target. Exact records still require endpoints. pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> bool { if kind == RelationKind::FunctionalExchange { return source != target && [source, target].iter().all(|id| { p.elements .get(id) .is_some_and(|e| e.layer() == Layer::Sa && e.kind() == Kind::Function) }); } if kind == RelationKind::Allocation && p.relations .values() .any(|r| r.kind() == kind && r.target() == target) { return false; } if kind == RelationKind::StateTransition && p.elements .get(&source) .is_some_and(|e| e.kind() == Kind::Initial) && p.relations .values() .any(|r| r.kind() == kind && r.source() == source) { return false; } let mut candidate = Relation { id: Id::nil(), alias: "candidate".into(), name: String::new(), description: String::new(), body: RelationBody::new(kind, source, target), }; if p.elements .get(&source) .is_some_and(|e| e.kind() == Kind::EntryPoint) && let RelationBody::StateTransition { transition_kind, .. } = &mut candidate.body { *transition_kind = TransitionKind::Local; } let mut issues = vec![]; validate_relation(p, &candidate, &mut issues); validate_relation_context(p, &candidate, &mut issues); if kind == RelationKind::StateTransition { for endpoint in [source, target] { if let Some(e) = p.elements.get(&endpoint) { crate::state_validation::validate_vertex(p, e, Some(&candidate), &mut issues); } } } !issues.iter().any(|i| i.severity == Severity::Error) } /// Feasibility for a boundary exchange with its actual orientation. This checks /// only the proposed relationship, so endpoint pickers need not clone and /// revalidate the complete model for every port. Legacy directions remain fixed /// even when the exchange is unoriented. pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool) -> bool { let candidate = Relation { id: Id::nil(), alias: "candidate".into(), name: String::new(), description: String::new(), body: RelationBody::BoundaryExchange { source, target, oriented, items: vec![], }, }; let mut issues = vec![]; validate_relation(p, &candidate, &mut issues); validate_relation_context(p, &candidate, &mut issues); !issues.iter().any(|i| i.severity == Severity::Error) } /// Aggregate display information only; this never creates inherited allocations. pub fn allocation_summary(p: &Project, work: Id) -> Option { let root = p.elements.get(&work)?; if !root.kind().is_work() { return None; } let mut leaves = Vec::new(); let mut pending = vec![work]; let mut seen = BTreeSet::new(); while let Some(id) = pending.pop() { if !seen.insert(id) { return None; } let children: Vec<_> = p .children(id) .filter(|e| e.kind() == root.kind()) .map(|e| e.id) .collect(); if children.is_empty() { leaves.push(id); } else { pending.extend(children); } } let mut performer = None; for leaf in leaves { let current = effective_performer(p, leaf)?; if performer.is_some_and(|p| p != current) { return None; } performer = Some(current); } performer } fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec) { crate::ports::validate_exchange_allocation(p, r, out); } pub fn effective_performer(p: &Project, work: Id) -> Option { let direct: Vec<_> = p .relations .values() .filter(|r| r.kind() == RelationKind::Allocation && r.target() == work) .map(Relation::source) .collect(); if direct.len() == 1 { return Some(direct[0]); } if !direct.is_empty() { return None; } let roles: BTreeSet<_> = p .relations .values() .filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == work) .map(Relation::source) .collect(); let performers: BTreeSet<_> = p .relations .values() .filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target())) .map(Relation::source) .collect(); (performers.len() == 1).then(|| *performers.first().unwrap()) } fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec) { if r.kind() == RelationKind::RoleAssignment && !p .relations .values() .any(|a| a.kind() == RelationKind::Allocation && a.target() == r.target()) { let roles: BTreeSet<_> = p .relations .values() .filter(|a| a.kind() == RelationKind::RoleAssignment && a.target() == r.target()) .map(Relation::source) .collect(); let performers: BTreeSet<_> = p .relations .values() .filter(|a| a.kind() == RelationKind::RoleRealization && roles.contains(&a.target())) .map(Relation::source) .collect(); if performers.len() > 1 { warning( out, r.id, "ambiguous_role_performer", "target", "Assigned roles are shared by several performers; no unique effective performer is inferred.", ); } } if r.kind() == RelationKind::BoundaryExchange && let (Some(a), Some(b)) = (p.elements.get(&r.source()), p.elements.get(&r.target())) && a.parent.is_some() && a.parent == b.parent { error( out, r.id, "boundary_owner", "endpoints", "A boundary exchange connects different participants.", ); } if matches!( r.kind(), RelationKind::Availability | RelationKind::StateAction ) { let (state, behavior) = if r.kind() == RelationKind::Availability { (r.target(), r.source()) } else { (r.source(), r.target()) }; let owner = ancestor_of_kind(p, state, Kind::StateMachine) .and_then(|id| p.elements.get(&id)) .and_then(|e| e.parent); let performer = p.elements.get(&behavior).and_then(|e| { if e.kind().is_performer() { Some(e.id) } else { effective_performer(p, e.id) } }); if let (Some(owner), Some(performer)) = (owner, performer) && owner != performer { error( out, r.id, "state_owner_context", "endpoints", "State availability/action must agree with the behavior's allocated participant.", ); } } } fn validate_occurrence_context( p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec, ) { if let Some(owner) = e.parent.and_then(|id| p.elements.get(&id)) { if owner.kind() == Kind::Occurrence { match owner.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { .. }, .. }) if matches!(body, OccurrenceBody::Branch { .. }) => {} Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) if !matches!(body, OccurrenceBody::Branch { .. }) => {} _ => error( out, e.id, "occurrence_owner", "parent", "Nested occurrences belong to branches; branches belong to fragments.", ), } } if let Some(steps) = owner.steps() && !steps.contains(&e.id) { warning( out, e.id, "unordered_occurrence", "parent", "Add this occurrence to its owner's ordered steps.", ); } if let OccurrenceBody::Exchange { exchange: Some(id), .. } = body && let Some(r) = p.relations.get(id) && (owner.kind() == Kind::Process && r.kind() != RelationKind::Interaction || owner.kind() == Kind::FunctionalChain && r.kind() != RelationKind::FunctionalExchange) { error( out, e.id, "sequence_exchange_kind", "exchange", "OA processes use interactions; SA functional chains use functional exchanges.", ); } } let check = |out: &mut Vec, line: Option, endpoint: Option, field: &str| { if let (Some(line), Some(endpoint)) = (line, endpoint) && lifeline_matches_endpoint(p, line, endpoint) == Some(false) { error( out, e.id, "scenario_endpoint", field, "The lifeline must represent the endpoint work, its explicitly assigned role, or its compatible participant.", ); } }; match body { OccurrenceBody::Exchange { sender, receiver, .. } => { if let Some((source, target)) = message_endpoints(p, body) { check(out, *sender, Some(source), "sender"); check(out, *receiver, Some(target), "receiver"); } } OccurrenceBody::Action { action, lifeline } => check(out, *lifeline, *action, "lifeline"), OccurrenceBody::State { state: Some(state), lifeline: Some(line), } => { let owner = ancestor_of_kind(p, *state, Kind::StateMachine) .and_then(|id| p.elements.get(&id)) .and_then(|e| e.parent); let represented = match p.elements.get(line).and_then(Element::shared) { Some(SharedBody::Lifeline { represented }) => *represented, _ => None, }; if let (Some(owner), Some(represented)) = (owner, represented) && owner != represented && effective_performer(p, represented) != Some(owner) { error( out, e.id, "scenario_state_owner", "lifeline", "State observation must agree with the state-machine owner.", ); } } _ => {} } }