Files
ARCADIA-Studio/crates/oa-core/src/validation.rs
T
sorokin.oleg.edu 5b08d8016b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Add persisted per-view port anchors and connection-aware border placement
2026-09-06 01:18:51 +03:00

2690 lines
96 KiB
Rust

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<Id>,
pub code: String,
pub field: Option<String>,
pub message: String,
}
fn issue(
out: &mut Vec<Issue>,
severity: Severity,
id: Option<Id>,
code: &str,
field: &str,
message: impl Into<String>,
) {
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<Issue>,
id: Id,
code: &str,
field: &str,
message: impl Into<String>,
) {
issue(out, Severity::Error, Some(id), code, field, message);
}
pub(crate) fn warning(
out: &mut Vec<Issue>,
id: Id,
code: &str,
field: &str,
message: impl Into<String>,
) {
issue(out, Severity::Warning, Some(id), code, field, message);
}
fn text(out: &mut Vec<Issue>, 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<Id> {
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<Issue>,
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<Issue>,
owner: Id,
id: Option<Id>,
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<Issue>, 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<Issue> {
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 {
definition: DataTypeDefinition::Enumeration { literals },
} => {
let mut seen = BTreeSet::new();
for literal in literals {
text(&mut out, e.id, "literals", literal);
if !seen.insert(literal) {
error(
&mut out,
e.id,
"duplicate_literal",
"literals",
"Enumeration literals must be unique.",
);
}
}
}
SharedBody::DataField {
type_ref,
minimum,
maximum,
unit,
} => {
if maximum.is_some_and(|max| max < *minimum) {
error(
&mut out,
e.id,
"invalid_multiplicity",
"maximum",
"Maximum multiplicity must be at least minimum, or unbounded.",
);
}
text(&mut out, e.id, "unit", unit);
if let TypeRef::Named(id) = type_ref {
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
}
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == Kind::DataType
&& !matches!(
parent.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record
})
)
{
error(
&mut out,
e.id,
"field_owner",
"parent",
"Fields under a data type require a Record type.",
);
}
}
SharedBody::Interface { items } => {
check_ids(&mut out, e.id, "items", items);
for id in items {
expect_element(p, &mut out, e.id, *id, "items", |k| {
k == Kind::ExchangeItem
});
}
}
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);
validate_relation_context(p, r, &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);
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 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.",
),
};
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);
}
}
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<Issue>) {
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<Id, Vec<Id>>) -> BTreeSet<Id> {
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<Issue>,
) {
let mut outgoing: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
let mut incoming: BTreeMap<Id, Vec<Id>> = 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, Vec<Id>>, 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<Id, Vec<Id>> = 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<ScenarioViewpoint> {
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<Id> {
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<bool> {
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<bool> {
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(&current)
.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<b)
}
};
}
parent.steps().is_some_and(|steps| matches!((steps.iter().position(|id|*id==a[common]),steps.iter().position(|id|*id==b[common])),(Some(a),Some(b))if a<b))
}
fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
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<Id> {
let mut pending = relation.body.items().to_vec();
let mut seen = BTreeSet::new();
let mut fields = BTreeSet::new();
while let Some(owner) = pending.pop() {
if !seen.insert(owner) {
continue;
}
for field in p.children(owner) {
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
fields.insert(field.id);
if let TypeRef::Named(id) = type_ref {
pending.push(*id);
}
}
}
}
fields
}
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<Issue>) {
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 allowed = exchange
.and_then(|id| p.relations.get(&id))
.map(|r| carried_fields(p, r));
let mut fields = BTreeSet::new();
for value in values {
expect_element(p, out, e.id, value.field, "values.field", |kind| {
kind == Kind::DataField
});
text(out, e.id, "values.expression", &value.expression);
if !fields.insert(value.field) {
error(
out,
e.id,
"duplicate_message_value",
"values",
"Each carried field has at most one contextual value annotation in a message.",
);
}
if allowed
.as_ref()
.is_some_and(|allowed| !allowed.contains(&value.field))
{
error(
out,
e.id,
"message_value_field",
"values.field",
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
);
}
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<Id>,
bindings: &[LifelineBinding],
out: &mut Vec<Issue>,
) {
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<Id, Vec<Id>> = 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<Issue>) {
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<Issue>, id: Option<Id>, 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<Issue>) {
let (source, target) = r.body.endpoints();
for id in crate::references::relation_references(r) {
if !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 && r.kind() != RelationKind::StateTransition)
{
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<Id>) -> 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 => {
*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<Id> {
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<Issue>) {
crate::ports::validate_exchange_allocation(p, r, out);
}
pub fn effective_performer(p: &Project, work: Id) -> Option<Id> {
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<Issue>) {
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<Issue>,
) {
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<Issue>, line: Option<Id>, endpoint: Option<Id>, 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.",
);
}
}
_ => {}
}
}