Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2690 lines
96 KiB
Rust
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(¤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<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.",
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|