Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
@@ -15,6 +15,7 @@ fn occurrence(
|
||||
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender,
|
||||
receiver,
|
||||
|
||||
@@ -84,7 +84,20 @@ impl ParseLimits {
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum();
|
||||
.sum::<usize>()
|
||||
.saturating_add(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.map(|r| match &r.body {
|
||||
RelationBody::ContextualLink { source, target, .. } => source
|
||||
.references
|
||||
.len()
|
||||
.saturating_add(target.references.len()),
|
||||
_ => 0,
|
||||
})
|
||||
.sum::<usize>(),
|
||||
);
|
||||
self.check_counts(graph, records, steps)
|
||||
}
|
||||
}
|
||||
@@ -343,6 +356,10 @@ fn reference_field(key: &str) -> bool {
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "references"
|
||||
| "occurrence"
|
||||
| "allocation"
|
||||
| "work"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
@@ -1386,9 +1403,10 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;', 'arcadia 5;' or 'arcadia 6;'."));
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
||||
return Err(
|
||||
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
||||
);
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
@@ -1606,6 +1624,7 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
@@ -1683,6 +1702,7 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
@@ -1691,7 +1711,9 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
let required: &[&str] = if kind == RelationKind::ContextualLink {
|
||||
&["owner", "source", "target", "purpose"]
|
||||
} else if kind == RelationKind::FunctionalExchange {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
} else {
|
||||
&["source", "target"]
|
||||
@@ -1722,6 +1744,7 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && fields.contains_key("view_options") {
|
||||
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
@@ -1852,11 +1875,13 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if project.schema_version == 4 || project.schema_version == 5 {
|
||||
if matches!(project.schema_version, 4..=6) {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else {
|
||||
} else if project.schema_version == 5 {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
|
||||
@@ -109,6 +109,10 @@ pub(super) fn resolve_baseline(
|
||||
continue;
|
||||
}
|
||||
let json = raw(value)?;
|
||||
if version < 7 {
|
||||
crate::persistence::reject_source7_fields(&json)
|
||||
.map_err(|cause| error(value, cause))?;
|
||||
}
|
||||
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
|
||||
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
|
||||
if version < 6 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
pub mod persistence;
|
||||
pub mod ports;
|
||||
pub mod projection;
|
||||
@@ -11,6 +12,7 @@ pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod topology_context;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub mod view_options;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 6;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -523,6 +523,28 @@ pub struct MessageValue {
|
||||
pub field: Id,
|
||||
pub expression: String,
|
||||
}
|
||||
/// Explicit operation intent. A missing allocation remains an unfinished operation,
|
||||
/// whereas an absent OperationUse retains ordinary channel-message semantics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationUse {
|
||||
pub allocation: Option<Id>,
|
||||
}
|
||||
/// A contribution in a caller instance. Paths contain reference occurrence IDs,
|
||||
/// never sequence definitions; repeated references therefore remain distinct.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OccurrenceEndpoint {
|
||||
pub references: Vec<Id>,
|
||||
pub occurrence: Id,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ContextualLinkPurpose {
|
||||
Precedence { guard: String },
|
||||
Exchange { exchange: Id },
|
||||
CommonWork { work: Id },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum OccurrenceBody {
|
||||
@@ -536,6 +558,8 @@ pub enum OccurrenceBody {
|
||||
reply_to: Option<Id>,
|
||||
#[serde(default)]
|
||||
values: Vec<MessageValue>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
operation: Option<OperationUse>,
|
||||
},
|
||||
Action {
|
||||
action: Option<Id>,
|
||||
@@ -797,6 +821,7 @@ impl ElementBody {
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
@@ -863,6 +888,7 @@ pub enum RelationKind {
|
||||
CapabilityGeneralization,
|
||||
MissionInvolvement,
|
||||
SequenceLink,
|
||||
ContextualLink,
|
||||
StateTransition,
|
||||
Availability,
|
||||
ExchangeAllocation,
|
||||
@@ -871,7 +897,7 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 24] = [
|
||||
pub const ALL: [Self; 25] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
Self::Allocation,
|
||||
@@ -890,6 +916,7 @@ impl RelationKind {
|
||||
Self::CapabilityGeneralization,
|
||||
Self::MissionInvolvement,
|
||||
Self::SequenceLink,
|
||||
Self::ContextualLink,
|
||||
Self::StateTransition,
|
||||
Self::Availability,
|
||||
Self::ExchangeAllocation,
|
||||
@@ -937,7 +964,9 @@ impl RelationKind {
|
||||
(Kind::Entity | Kind::Actor, Kind::OperationalMission)
|
||||
| (Kind::System | Kind::SystemActor, Kind::Mission)
|
||||
),
|
||||
Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence,
|
||||
Self::SequenceLink | Self::ContextualLink => {
|
||||
from == Kind::Occurrence && to == Kind::Occurrence
|
||||
}
|
||||
Self::StateTransition => from.is_state() && to.is_state(),
|
||||
Self::ExchangeAllocation => false,
|
||||
Self::InterfaceProvision | Self::InterfaceRequirement => {
|
||||
@@ -980,6 +1009,9 @@ impl RelationKind {
|
||||
Self::CapabilityGeneralization => "Specific capability → general capability",
|
||||
Self::MissionInvolvement => "Participant → mission in the same perspective",
|
||||
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
|
||||
Self::ContextualLink => {
|
||||
"Contributions addressed in an explicit process or chain caller"
|
||||
}
|
||||
Self::StateTransition => "State → state in one machine",
|
||||
Self::ExchangeAllocation => {
|
||||
"Communication means / boundary exchange → operational interaction / functional exchange"
|
||||
@@ -1012,6 +1044,7 @@ impl fmt::Display for RelationKind {
|
||||
Self::CapabilityGeneralization => "Capability generalization",
|
||||
Self::MissionInvolvement => "Mission involvement",
|
||||
Self::SequenceLink => "Sequence link",
|
||||
Self::ContextualLink => "Contextual link",
|
||||
Self::StateTransition => "State transition",
|
||||
Self::ExchangeAllocation => "Exchange allocation",
|
||||
Self::InterfaceProvision => "Interface provision",
|
||||
@@ -1126,6 +1159,12 @@ pub enum RelationBody {
|
||||
target: Id,
|
||||
guard: String,
|
||||
},
|
||||
ContextualLink {
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
purpose: ContextualLinkPurpose,
|
||||
},
|
||||
StateTransition {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1162,6 +1201,13 @@ pub enum RelationBody {
|
||||
},
|
||||
}
|
||||
impl Relation {
|
||||
/// Caller ownership cannot be inferred from a contextual link's leaf IDs.
|
||||
pub fn contextual_owner(&self) -> Option<Id> {
|
||||
match self.body {
|
||||
RelationBody::ContextualLink { owner, .. } => Some(owner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
self.body.kind()
|
||||
}
|
||||
@@ -1193,6 +1239,7 @@ impl RelationBody {
|
||||
Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization,
|
||||
Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement,
|
||||
Self::SequenceLink { .. } => RelationKind::SequenceLink,
|
||||
Self::ContextualLink { .. } => RelationKind::ContextualLink,
|
||||
Self::StateTransition { .. } => RelationKind::StateTransition,
|
||||
Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation,
|
||||
Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision,
|
||||
@@ -1203,6 +1250,7 @@ impl RelationBody {
|
||||
}
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence),
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
@@ -1231,6 +1279,10 @@ impl RelationBody {
|
||||
}
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::ContextualLink { source, target, .. } => {
|
||||
source.occurrence = a;
|
||||
target.occurrence = b;
|
||||
}
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
@@ -1337,6 +1389,20 @@ impl RelationBody {
|
||||
target,
|
||||
guard: String::new(),
|
||||
},
|
||||
RelationKind::ContextualLink => Self::ContextualLink {
|
||||
owner: Id::nil(),
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: source,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: target,
|
||||
},
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: String::new(),
|
||||
},
|
||||
},
|
||||
RelationKind::StateTransition => Self::StateTransition {
|
||||
source,
|
||||
target,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! One operation resolver for admission, forms and scenario labels.
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOperation {
|
||||
/// The actual Call/Asynchronous declaration, also when resolving its Reply.
|
||||
pub invocation: Id,
|
||||
pub allocation: Id,
|
||||
pub interface: Id,
|
||||
pub item: Id,
|
||||
pub caller_port: Id,
|
||||
pub provider_port: Id,
|
||||
pub transmission: TransmissionProtocol,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OperationResolution {
|
||||
Ordinary,
|
||||
Unresolved { invocation: Id, reason: String },
|
||||
Selected(ResolvedOperation),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct OperationError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
fn error(code: &'static str, message: &str) -> OperationError {
|
||||
OperationError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(p: &Project, occurrence: Id) -> Result<OperationResolution, OperationError> {
|
||||
let e = p
|
||||
.elements
|
||||
.get(&occurrence)
|
||||
.ok_or_else(|| error("operation_context", "The message no longer exists."))?;
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
operation,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
let invocation = if *message_kind == MessageKind::Reply {
|
||||
if operation.is_some() {
|
||||
return Err(error(
|
||||
"reply_operation",
|
||||
"A Reply derives its operation from its exact Call; it cannot store a second selection.",
|
||||
));
|
||||
}
|
||||
let Some(call) = reply_to else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
if !matches!(
|
||||
p.elements.get(call).and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
}
|
||||
*call
|
||||
} else {
|
||||
occurrence
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
body @ OccurrenceBody::Exchange {
|
||||
operation: Some(operation),
|
||||
message_kind,
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&invocation).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
if p.layer_of(invocation) != Some(Layer::Sa)
|
||||
|| !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|
||||
|| validation::ancestor_of_kind(p, invocation, Kind::Scenario)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
.is_none_or(|body| {
|
||||
!matches!(
|
||||
body,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(error(
|
||||
"operation_context",
|
||||
"Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.",
|
||||
));
|
||||
}
|
||||
let unresolved = |reason: &str| {
|
||||
Ok(OperationResolution::Unresolved {
|
||||
invocation,
|
||||
reason: reason.into(),
|
||||
})
|
||||
};
|
||||
if exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.is_some_and(|r| r.kind() != RelationKind::BoundaryExchange)
|
||||
{
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"Operation use requires an explicit boundary exchange channel.",
|
||||
));
|
||||
}
|
||||
let Some(allocation) = operation.allocation else {
|
||||
return unresolved(
|
||||
"Choose the interface operation allocation; existing value expressions are preserved.",
|
||||
);
|
||||
};
|
||||
let Some(relation) = p.relations.get(&allocation) else {
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"The selected interface operation allocation no longer exists.",
|
||||
));
|
||||
};
|
||||
let RelationBody::InterfaceItemAllocation {
|
||||
source: interface,
|
||||
target: item,
|
||||
transmission,
|
||||
..
|
||||
} = relation.body
|
||||
else {
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"Choose an InterfaceItemAllocation, not an item or another relationship.",
|
||||
));
|
||||
};
|
||||
if p.layer_of(allocation) != Some(Layer::Sa)
|
||||
|| !matches!(
|
||||
p.elements.get(&item).and_then(Element::shared),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"The selected SA allocation must declare an Information item with Operation mechanism.",
|
||||
));
|
||||
}
|
||||
let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else {
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
};
|
||||
if channel.kind() != RelationKind::BoundaryExchange
|
||||
|| !channel
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|carried| data::item_satisfies(p, *carried, item))
|
||||
{
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"The boundary exchange must carry the selected operation item or an admitted subtype.",
|
||||
));
|
||||
}
|
||||
if sender.is_none() || receiver.is_none() {
|
||||
return Err(error(
|
||||
"operation_lifelines",
|
||||
"A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
}
|
||||
let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) {
|
||||
validation::MessageEndpointResolution::Resolved { source, target } => (source, target),
|
||||
validation::MessageEndpointResolution::Conflict => {
|
||||
return Err(error(
|
||||
"operation_direction",
|
||||
"The selected lifelines conflict with the boundary channel's actual caller/provider direction.",
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"operation_direction",
|
||||
"A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
}
|
||||
};
|
||||
for port in [caller_port, provider_port] {
|
||||
if !matches!(
|
||||
p.elements.get(&port).and_then(ports::port_contract),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Err(error(
|
||||
"operation_ports",
|
||||
"Operation proof requires concrete Standard component ports.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let exposes = |interface| {
|
||||
data::effective_interface_allocations(p, interface)
|
||||
.iter()
|
||||
.any(|r| r.id == allocation)
|
||||
};
|
||||
let providers: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::InterfaceProvision
|
||||
&& r.source() == provider_port
|
||||
&& exposes(r.target())
|
||||
})
|
||||
.map(Relation::target)
|
||||
.collect();
|
||||
let valid = p.relations.values().any(|r| {
|
||||
r.kind() == RelationKind::InterfaceRequirement
|
||||
&& r.source() == caller_port
|
||||
&& exposes(r.target())
|
||||
&& providers
|
||||
.iter()
|
||||
.any(|provided| data::interface_satisfies(p, *provided, r.target()))
|
||||
});
|
||||
if !valid {
|
||||
return Err(error(
|
||||
"operation_roles",
|
||||
"The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.",
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
(message_kind, transmission),
|
||||
(_, TransmissionProtocol::Unspecified)
|
||||
| (MessageKind::Call, TransmissionProtocol::Synchronous)
|
||||
| (
|
||||
MessageKind::Asynchronous,
|
||||
TransmissionProtocol::Asynchronous
|
||||
)
|
||||
) {
|
||||
return Err(error(
|
||||
"operation_transmission",
|
||||
"Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.",
|
||||
));
|
||||
}
|
||||
let selected = ResolvedOperation {
|
||||
invocation,
|
||||
allocation,
|
||||
interface,
|
||||
item,
|
||||
caller_port,
|
||||
provider_port,
|
||||
transmission,
|
||||
};
|
||||
if occurrence != invocation {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: reply_exchange,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id))
|
||||
&& response.id != channel.id
|
||||
&& !response
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|carried| data::item_satisfies(p, *carried, item))
|
||||
{
|
||||
return Err(error(
|
||||
"operation_reply_channel",
|
||||
"A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(OperationResolution::Selected(selected))
|
||||
}
|
||||
|
||||
/// Valid selections under the proposed message context, without mutating the model.
|
||||
pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec<Id> {
|
||||
let mut candidate = p.clone();
|
||||
let ids: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
.map(|r| r.id)
|
||||
.collect();
|
||||
ids.into_iter()
|
||||
.filter(|id| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = candidate
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.and_then(Element::shared_mut)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(*id),
|
||||
});
|
||||
matches!(
|
||||
resolve(&candidate, occurrence),
|
||||
Ok(OperationResolution::Selected(_))
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reuse the existing bounded field-path engine with exactly one selected item.
|
||||
pub fn signature_relation(operation: &ResolvedOperation) -> Relation {
|
||||
Relation {
|
||||
id: operation.allocation,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: operation.caller_port,
|
||||
target: operation.provider_port,
|
||||
items: vec![operation.item],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,13 @@ pub fn decode_with_report_limited(
|
||||
}
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 6)
|
||||
{
|
||||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||||
}
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
@@ -182,13 +189,14 @@ pub fn decode_with_report_limited(
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => DecodeResult {
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5 or 6.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -357,6 +365,70 @@ pub(crate) fn migrate_v5(
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||||
/// This is also applied to source's resolved structured values.
|
||||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||||
);
|
||||
}
|
||||
for nested in fields.values() {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for nested in values {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn migrate_v6(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(6)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
|
||||
@@ -87,8 +87,15 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
if let Some(id) = operation
|
||||
.as_mut()
|
||||
.and_then(|operation| operation.allocation.as_mut())
|
||||
{
|
||||
visit(id);
|
||||
}
|
||||
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
@@ -150,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(&mut target);
|
||||
r.body.set_endpoints(source, target);
|
||||
match &mut r.body {
|
||||
RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} => {
|
||||
visit(owner);
|
||||
for id in source.references.iter_mut().chain(&mut target.references) {
|
||||
visit(id);
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Exchange { exchange } => visit(exchange),
|
||||
ContextualLinkPurpose::CommonWork { work } => visit(work),
|
||||
ContextualLinkPurpose::Precedence { .. } => {}
|
||||
}
|
||||
}
|
||||
RelationBody::StateTransition {
|
||||
triggers, effects, ..
|
||||
} => {
|
||||
@@ -274,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
});
|
||||
if removed.contains(&r.source())
|
||||
|| removed.contains(&r.target())
|
||||
|| (r.kind() == RelationKind::ContextualLink
|
||||
&& relation_references(r).iter().any(|id| removed.contains(id)))
|
||||
|| port_removed
|
||||
|| work_allocation_removed
|
||||
{
|
||||
@@ -444,8 +469,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
if let Some(operation) = operation {
|
||||
clear(&mut operation.allocation);
|
||||
}
|
||||
clear(exchange);
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
|
||||
@@ -81,6 +81,35 @@ impl Own {
|
||||
}
|
||||
let kind = enum_tag(value)?.to_string();
|
||||
prefix.push(kind.clone());
|
||||
let mut whole = whole;
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = project.elements.get(&id).and_then(Element::shared)
|
||||
&& project.layer_of(id) == Some(Layer::Sa)
|
||||
&& crate::validation::ancestor_of_kind(project, id, Kind::Scenario)
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
.is_some_and(|body| {
|
||||
matches!(
|
||||
body,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut operation_path = prefix.clone();
|
||||
operation_path.extend(["body".into(), "Exchange".into()]);
|
||||
if let Some(body) = at_mut(&mut whole, &operation_path).and_then(Value::as_object_mut) {
|
||||
body.entry("operation").or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
whole,
|
||||
prefix,
|
||||
@@ -163,6 +192,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Occurrence(OccurrenceFacet::ReplyTo) => {
|
||||
("Occurrence", &["body", "Exchange", "reply_to"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Operation) => {
|
||||
("Occurrence", &["body", "Exchange", "operation"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
|
||||
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
|
||||
("Occurrence", &["body", "Action", "lifeline"])
|
||||
@@ -192,8 +224,12 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
|
||||
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
|
||||
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
|
||||
F::Relation(RelationFacet::Source) => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]),
|
||||
F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]),
|
||||
F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]),
|
||||
F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]),
|
||||
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
|
||||
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
|
||||
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
|
||||
@@ -926,6 +962,7 @@ mod coverage {
|
||||
] {
|
||||
add(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: Some(id),
|
||||
receiver: Some(id),
|
||||
|
||||
@@ -171,6 +171,31 @@ impl Historical for LifelineBinding {
|
||||
w.at("local", |w| self.local.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for OperationUse {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.at("allocation", |w| self.allocation.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for OccurrenceEndpoint {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH {
|
||||
w.error("historical contextual path depth exceeded");
|
||||
}
|
||||
w.at("references", |w| self.references.walk(w));
|
||||
w.at("occurrence", |w| self.occurrence.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for ContextualLinkPurpose {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))),
|
||||
Self::Exchange { exchange } => {
|
||||
w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w)))
|
||||
}
|
||||
Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for DataValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
|
||||
@@ -518,7 +518,11 @@ pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector)
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
|
||||
facet,
|
||||
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
|
||||
F::Occurrence(
|
||||
OccurrenceFacet::MessageKind
|
||||
| OccurrenceFacet::TimerExpression
|
||||
| OccurrenceFacet::Operation
|
||||
)
|
||||
),
|
||||
F::Event(EventFacet::Constructor) => matches!(
|
||||
facet,
|
||||
|
||||
@@ -112,6 +112,7 @@ macro_rules! scalar_families {
|
||||
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
|
||||
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
|
||||
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
|
||||
Operation: Option<OperationUse>,
|
||||
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
|
||||
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
|
||||
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
|
||||
@@ -129,6 +130,8 @@ macro_rules! scalar_families {
|
||||
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
|
||||
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
|
||||
Guard: String, Effect: String, StateActionPhase: StateActionKind
|
||||
, ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint,
|
||||
ContextPurpose: ContextualLinkPurpose
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -660,6 +660,7 @@ fn apply_command(
|
||||
.unwrap();
|
||||
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(*exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -1553,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
|
||||
return element.parent;
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
return Some(relation.source());
|
||||
return Some(
|
||||
relation
|
||||
.contextual_owner()
|
||||
.unwrap_or_else(|| relation.source()),
|
||||
);
|
||||
}
|
||||
if let Some(artifact) = project.artifact_links.get(&id) {
|
||||
return Some(artifact.owner);
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Caller-qualified process/chain addresses. Cache residency never affects admission.
|
||||
use crate::*;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
};
|
||||
|
||||
mod analysis;
|
||||
pub use analysis::*;
|
||||
pub const MAX_REFERENCE_DEPTH: usize = 64;
|
||||
pub const MAX_ANALYSIS_NODES: usize = 50_000;
|
||||
pub const MAX_ANALYSIS_EDGES: usize = 100_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContextAddress {
|
||||
pub owner: Id,
|
||||
pub endpoint: OccurrenceEndpoint,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOccurrence {
|
||||
pub address: ContextAddress,
|
||||
pub defining_sequence: Id,
|
||||
pub work: Option<Id>,
|
||||
pub exchange: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct ContextError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
fn failure(code: &'static str, message: &str) -> ContextError {
|
||||
ContextError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
struct Record {
|
||||
parent: Option<Id>,
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
reference: Option<Id>,
|
||||
work: Option<Id>,
|
||||
exchange: Option<Id>,
|
||||
contribution: bool,
|
||||
}
|
||||
pub struct TopologyIndex<'a> {
|
||||
project: &'a Project,
|
||||
records: BTreeMap<Id, Record>,
|
||||
children: BTreeMap<Id, Vec<Id>>,
|
||||
link_definitions: BTreeMap<Id, Vec<Id>>,
|
||||
contextual_callers: BTreeSet<Id>,
|
||||
cache: RefCell<BTreeMap<ContextAddress, ResolvedOccurrence>>,
|
||||
cache_capacity: usize,
|
||||
}
|
||||
impl<'a> TopologyIndex<'a> {
|
||||
pub fn new(project: &'a Project) -> Self {
|
||||
Self::with_cache_capacity(project, 4096)
|
||||
}
|
||||
pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self {
|
||||
let records: BTreeMap<_, _> = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| {
|
||||
let (reference, work, exchange, contribution) = match e.shared() {
|
||||
Some(SharedBody::Occurrence { body, .. }) => match body {
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
(*sequence, None, None, true)
|
||||
}
|
||||
OccurrenceBody::Action { action, .. } => (None, *action, None, true),
|
||||
OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true),
|
||||
OccurrenceBody::Control { .. } => (None, None, None, true),
|
||||
_ => (None, None, None, false),
|
||||
},
|
||||
_ => (None, None, None, false),
|
||||
};
|
||||
(
|
||||
e.id,
|
||||
Record {
|
||||
parent: e.parent,
|
||||
layer: e.layer(),
|
||||
kind: e.kind(),
|
||||
reference,
|
||||
work,
|
||||
exchange,
|
||||
contribution,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut children: BTreeMap<_, _> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| e.steps().map(|steps| (e.id, steps.clone())))
|
||||
.collect();
|
||||
let mut child_sets: BTreeMap<_, BTreeSet<_>> = children
|
||||
.iter()
|
||||
.map(|(owner, children)| (*owner, children.iter().copied().collect()))
|
||||
.collect();
|
||||
for (id, record) in &records {
|
||||
if record.contribution
|
||||
&& let Some(parent) = record.parent
|
||||
{
|
||||
let list = children.entry(parent).or_default();
|
||||
if child_sets.entry(parent).or_default().insert(*id) {
|
||||
list.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (owner, list) in &mut children {
|
||||
list.retain(|id| {
|
||||
records
|
||||
.get(id)
|
||||
.is_some_and(|r| r.parent == Some(*owner) && r.contribution)
|
||||
});
|
||||
}
|
||||
let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut contextual_callers = BTreeSet::new();
|
||||
for relation in project.relations.values() {
|
||||
let owner = relation.contextual_owner().or_else(|| {
|
||||
if relation.kind() == RelationKind::SequenceLink {
|
||||
records.get(&relation.source()).and_then(|r| r.parent)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(owner) = owner {
|
||||
link_definitions.entry(owner).or_default().push(relation.id);
|
||||
}
|
||||
if let Some(owner) = relation.contextual_owner() {
|
||||
contextual_callers.insert(owner);
|
||||
}
|
||||
}
|
||||
let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for record in records.values() {
|
||||
if let (Some(parent), Some(reference)) = (record.parent, record.reference) {
|
||||
callers.entry(reference).or_default().push(parent);
|
||||
}
|
||||
}
|
||||
let mut pending: Vec<_> = contextual_callers.iter().copied().collect();
|
||||
while let Some(sequence) = pending.pop() {
|
||||
for caller in callers.get(&sequence).into_iter().flatten() {
|
||||
if contextual_callers.insert(*caller) {
|
||||
pending.push(*caller);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project,
|
||||
records,
|
||||
children,
|
||||
link_definitions,
|
||||
contextual_callers,
|
||||
cache: RefCell::new(BTreeMap::new()),
|
||||
cache_capacity,
|
||||
}
|
||||
}
|
||||
/// Resolve a caller path while an endpoint leaf is still being edited.
|
||||
pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result<Id, ContextError> {
|
||||
if references.len() > MAX_REFERENCE_DEPTH {
|
||||
return Err(failure(
|
||||
"context_depth",
|
||||
"A contextual path exceeds 64 references.",
|
||||
));
|
||||
}
|
||||
let root = self.records.get(&owner).ok_or_else(|| {
|
||||
failure(
|
||||
"context_owner",
|
||||
"The caller must be an existing process or functional chain.",
|
||||
)
|
||||
})?;
|
||||
if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) {
|
||||
return Err(failure(
|
||||
"context_owner",
|
||||
"The caller must be a process or functional chain.",
|
||||
));
|
||||
}
|
||||
let mut current = owner;
|
||||
let mut visited = BTreeSet::from([owner]);
|
||||
for step in references {
|
||||
let reference = self.records.get(step).ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"A reference occurrence in the endpoint path no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if reference.parent != Some(current) || reference.layer != root.layer {
|
||||
return Err(failure(
|
||||
"context_path",
|
||||
"Each reference occurrence must belong directly to the preceding caller context.",
|
||||
));
|
||||
}
|
||||
let next = reference.reference.ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"Each path component must be a resolved sequence-reference occurrence.",
|
||||
)
|
||||
})?;
|
||||
let target = self.records.get(&next).ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"A referenced process or chain no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if target.layer != root.layer || target.kind != root.kind {
|
||||
return Err(failure(
|
||||
"context_path",
|
||||
"References must resolve to sequences of the same kind and layer.",
|
||||
));
|
||||
}
|
||||
if !visited.insert(next) {
|
||||
return Err(failure(
|
||||
"context_cycle",
|
||||
"A contextual endpoint cannot traverse recursive sequence reuse.",
|
||||
));
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
pub fn resolve(
|
||||
&self,
|
||||
owner: Id,
|
||||
endpoint: &OccurrenceEndpoint,
|
||||
) -> Result<ResolvedOccurrence, ContextError> {
|
||||
if endpoint.references.len() > MAX_REFERENCE_DEPTH {
|
||||
return Err(failure(
|
||||
"context_depth",
|
||||
"A contextual endpoint exceeds the 64-reference depth limit.",
|
||||
));
|
||||
}
|
||||
let address = ContextAddress {
|
||||
owner,
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
if let Some(cached) = self.cache.borrow().get(&address) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let current = self.resolve_scope(owner, &endpoint.references)?;
|
||||
let root = &self.records[&owner];
|
||||
let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| {
|
||||
failure(
|
||||
"context_leaf",
|
||||
"The endpoint contribution no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution {
|
||||
return Err(failure(
|
||||
"context_leaf",
|
||||
"The endpoint must be a direct contribution of its resolved process or chain.",
|
||||
));
|
||||
}
|
||||
let resolved = ResolvedOccurrence {
|
||||
address: address.clone(),
|
||||
defining_sequence: current,
|
||||
work: leaf.work,
|
||||
exchange: leaf.exchange,
|
||||
};
|
||||
if self.cache_capacity > 0 {
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
if cache.len() >= self.cache_capacity {
|
||||
cache.pop_first();
|
||||
}
|
||||
cache.insert(address, resolved.clone());
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
/// Validate a caller-owned semantic connection, independent of graphical expansion.
|
||||
pub fn validate_link(
|
||||
&self,
|
||||
owner: Id,
|
||||
source: &OccurrenceEndpoint,
|
||||
target: &OccurrenceEndpoint,
|
||||
purpose: &ContextualLinkPurpose,
|
||||
) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> {
|
||||
let a = self.resolve(owner, source)?;
|
||||
let b = self.resolve(owner, target)?;
|
||||
if a.address == b.address {
|
||||
return Err(failure(
|
||||
"context_self",
|
||||
"A contextual link needs two distinct occurrence addresses.",
|
||||
));
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
if source.references.is_empty() && target.references.is_empty() {
|
||||
return Err(failure(
|
||||
"context_direct_precedence",
|
||||
"Use a SequenceLink for precedence between two direct contributions.",
|
||||
));
|
||||
}
|
||||
if !validation::valid_text(guard) {
|
||||
return Err(failure(
|
||||
"invalid_text",
|
||||
"The precedence guard contains invalid text.",
|
||||
));
|
||||
}
|
||||
let previous = a.exchange.and_then(|id| self.project.relations.get(&id));
|
||||
let next = b.exchange.and_then(|id| self.project.relations.get(&id));
|
||||
let mismatch = match (a.work, previous, b.work, next) {
|
||||
(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 {
|
||||
return Err(failure(
|
||||
"sequence_exchange_endpoint",
|
||||
"Adjacent work/exchange contributions must agree with the referenced exchange endpoints.",
|
||||
));
|
||||
}
|
||||
}
|
||||
ContextualLinkPurpose::Exchange { exchange } => {
|
||||
let relation = self.project.relations.get(exchange).ok_or_else(|| {
|
||||
failure("context_exchange", "Choose an existing work exchange.")
|
||||
})?;
|
||||
let expected = if self.project.layer_of(owner) == Some(Layer::Oa) {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
};
|
||||
if relation.kind() != expected
|
||||
|| self.project.layer_of(*exchange) != self.project.layer_of(owner)
|
||||
|| a.work != Some(relation.source())
|
||||
|| b.work != Some(relation.target())
|
||||
{
|
||||
return Err(failure(
|
||||
"context_exchange",
|
||||
"The selected same-layer exchange must connect the exact work referenced by the source and target Actions.",
|
||||
));
|
||||
}
|
||||
}
|
||||
ContextualLinkPurpose::CommonWork { work } => {
|
||||
if !self.project.elements.get(work).is_some_and(|e| {
|
||||
e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner)
|
||||
}) || a.work != Some(*work)
|
||||
|| b.work != Some(*work)
|
||||
{
|
||||
return Err(failure(
|
||||
"context_common_work",
|
||||
"Both Actions must reference the exact same existing work in this layer.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
/// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree.
|
||||
pub fn contributions(&self, sequence: Id) -> Vec<Id> {
|
||||
self.children.get(&sequence).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "analysis_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ReferenceBoundary {
|
||||
Entry,
|
||||
Exit,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContextVertex {
|
||||
pub address: ContextAddress,
|
||||
pub boundary: Option<ReferenceBoundary>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContextEdgeKind {
|
||||
Authored,
|
||||
SyntheticBoundary,
|
||||
LegacyOrder,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextEdge {
|
||||
pub relation: Option<Id>,
|
||||
pub instance: Vec<Id>,
|
||||
pub source: ContextVertex,
|
||||
pub target: ContextVertex,
|
||||
pub guard: String,
|
||||
pub kind: ContextEdgeKind,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextAssembly {
|
||||
pub relation: Id,
|
||||
pub instance: Vec<Id>,
|
||||
pub source: ContextAddress,
|
||||
pub target: ContextAddress,
|
||||
pub purpose: ContextualLinkPurpose,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextNotice {
|
||||
pub address: ContextAddress,
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
pub relations: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AnalysisLimits {
|
||||
pub nodes: usize,
|
||||
pub edges: usize,
|
||||
}
|
||||
impl Default for AnalysisLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nodes: MAX_ANALYSIS_NODES,
|
||||
edges: MAX_ANALYSIS_EDGES,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextAnalysis {
|
||||
pub nodes: BTreeMap<ContextAddress, ResolvedOccurrence>,
|
||||
pub precedence: Vec<ContextEdge>,
|
||||
pub assembly: Vec<ContextAssembly>,
|
||||
pub entries: Vec<ContextVertex>,
|
||||
pub exits: Vec<ContextVertex>,
|
||||
pub notices: Vec<ContextNotice>,
|
||||
/// A partial expansion must never be used for complete reachability conclusions.
|
||||
pub complete: bool,
|
||||
}
|
||||
struct Builder<'a, 'p> {
|
||||
index: &'a TopologyIndex<'p>,
|
||||
root: Id,
|
||||
limits: AnalysisLimits,
|
||||
result: ContextAnalysis,
|
||||
definitions: BTreeMap<Id, Vec<&'p Relation>>,
|
||||
examined_edges: usize,
|
||||
exhausted: bool,
|
||||
}
|
||||
impl TopologyIndex<'_> {
|
||||
pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis {
|
||||
self.analyze_counted(owner, limits).0
|
||||
}
|
||||
fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) {
|
||||
let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for (owner, relations) in &self.link_definitions {
|
||||
definitions.insert(
|
||||
*owner,
|
||||
relations
|
||||
.iter()
|
||||
.filter_map(|id| self.project.relations.get(id))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let mut b = Builder {
|
||||
index: self,
|
||||
root: owner,
|
||||
limits,
|
||||
result: ContextAnalysis {
|
||||
complete: true,
|
||||
..ContextAnalysis::default()
|
||||
},
|
||||
definitions,
|
||||
examined_edges: 0,
|
||||
exhausted: false,
|
||||
};
|
||||
(b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new());
|
||||
if b.result.complete {
|
||||
b.cardinality();
|
||||
b.connectivity();
|
||||
}
|
||||
(b.result, b.examined_edges)
|
||||
}
|
||||
pub fn has_contextual_links(&self, owner: Id) -> bool {
|
||||
self.contextual_callers.contains(&owner)
|
||||
}
|
||||
}
|
||||
impl Builder<'_, '_> {
|
||||
fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress {
|
||||
ContextAddress {
|
||||
owner: self.root,
|
||||
endpoint: OccurrenceEndpoint {
|
||||
references: path.to_vec(),
|
||||
occurrence,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex {
|
||||
let reference = self
|
||||
.index
|
||||
.project
|
||||
.elements
|
||||
.get(&address.endpoint.occurrence)
|
||||
.and_then(Element::shared)
|
||||
.is_some_and(|s| {
|
||||
matches!(
|
||||
s,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
ContextVertex {
|
||||
address,
|
||||
boundary: reference.then_some(if source {
|
||||
ReferenceBoundary::Exit
|
||||
} else {
|
||||
ReferenceBoundary::Entry
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) {
|
||||
self.result.complete = false;
|
||||
if !self
|
||||
.result
|
||||
.notices
|
||||
.iter()
|
||||
.any(|n| n.address == address && n.code == code)
|
||||
{
|
||||
self.result.notices.push(ContextNotice {
|
||||
address,
|
||||
code,
|
||||
message: message.into(),
|
||||
relations: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reserve work before resolving an authored endpoint, not merely before storing
|
||||
// its output. Repeated malformed or filtered endpoints consume the same budget.
|
||||
fn reserve_edge(&mut self, address: ContextAddress) -> bool {
|
||||
if self.exhausted {
|
||||
return false;
|
||||
}
|
||||
if self.examined_edges >= self.limits.edges {
|
||||
self.exhausted = true;
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_limit",
|
||||
"Contextual edge limit reached; connectivity analysis is incomplete.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.examined_edges += 1;
|
||||
true
|
||||
}
|
||||
fn edge(&mut self, edge: ContextEdge) -> bool {
|
||||
if !self.reserve_edge(edge.source.address.clone()) {
|
||||
return false;
|
||||
}
|
||||
self.result.precedence.push(edge);
|
||||
true
|
||||
}
|
||||
fn expand(
|
||||
&mut self,
|
||||
sequence: Id,
|
||||
prefix: &[Id],
|
||||
active: &mut BTreeSet<Id>,
|
||||
) -> (Vec<ContextVertex>, Vec<ContextVertex>) {
|
||||
if self.exhausted {
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) {
|
||||
self.incomplete(
|
||||
self.address(prefix, sequence),
|
||||
"context_analysis_incomplete",
|
||||
"Recursive or over-depth reuse has no proven boundary path.",
|
||||
);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let children = self.index.contributions(sequence);
|
||||
if children.is_empty() {
|
||||
self.incomplete(
|
||||
self.address(prefix, sequence),
|
||||
"context_analysis_incomplete",
|
||||
"An empty or unresolved sequence has no proven boundary path.",
|
||||
);
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let start = self.result.precedence.len();
|
||||
for id in &children {
|
||||
let address = self.address(prefix, *id);
|
||||
if self.result.nodes.len() >= self.limits.nodes {
|
||||
self.exhausted = true;
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_limit",
|
||||
"Contextual node limit reached; connectivity analysis is incomplete.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
match self.index.resolve(self.root, &address.endpoint) {
|
||||
Ok(resolved) => {
|
||||
self.result.nodes.insert(address.clone(), resolved);
|
||||
}
|
||||
Err(e) => {
|
||||
self.incomplete(address, "context_analysis_incomplete", &e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let reference = self.index.project.elements[id]
|
||||
.shared()
|
||||
.and_then(|s| match s {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => Some(*sequence),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(reference) = reference {
|
||||
let Some(callee) = reference else {
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_incomplete",
|
||||
"An unresolved reference has no proven boundary path.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut path = prefix.to_vec();
|
||||
path.push(*id);
|
||||
let (entries, exits) = self.expand(callee, &path, active);
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if entries.is_empty() || exits.is_empty() {
|
||||
self.incomplete(
|
||||
address.clone(),
|
||||
"context_analysis_incomplete",
|
||||
"No referenced entry or exit is proven; no bypass is inferred.",
|
||||
);
|
||||
}
|
||||
for target in entries {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(address.clone(), false),
|
||||
target,
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::SyntheticBoundary,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for source in exits {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source,
|
||||
target: self.vertex(address.clone(), true),
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::SyntheticBoundary,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let relations = self.definitions.get(&sequence).cloned().unwrap_or_default();
|
||||
for r in &relations {
|
||||
if !self.reserve_edge(self.address(prefix, r.source())) {
|
||||
break;
|
||||
}
|
||||
let (source, target, purpose) = match &r.body {
|
||||
RelationBody::SequenceLink {
|
||||
source,
|
||||
target,
|
||||
guard,
|
||||
} => (
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: *source,
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: *target,
|
||||
},
|
||||
ContextualLinkPurpose::Precedence {
|
||||
guard: guard.clone(),
|
||||
},
|
||||
),
|
||||
RelationBody::ContextualLink {
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
..
|
||||
} => (source.clone(), target.clone(), purpose.clone()),
|
||||
_ => continue,
|
||||
};
|
||||
let qualify = |mut endpoint: OccurrenceEndpoint| {
|
||||
let mut path = prefix.to_vec();
|
||||
path.extend(endpoint.references);
|
||||
endpoint.references = path;
|
||||
ContextAddress {
|
||||
owner: self.root,
|
||||
endpoint,
|
||||
}
|
||||
};
|
||||
let source = qualify(source);
|
||||
let target = qualify(target);
|
||||
if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target)
|
||||
{
|
||||
self.incomplete(
|
||||
source,
|
||||
"context_analysis_incomplete",
|
||||
"A link endpoint is unresolved in this bounded analysis.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
self.result.precedence.push(ContextEdge {
|
||||
relation: Some(r.id),
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(source, true),
|
||||
target: self.vertex(target, false),
|
||||
guard,
|
||||
kind: ContextEdgeKind::Authored,
|
||||
})
|
||||
}
|
||||
purpose => {
|
||||
self.result.assembly.push(ContextAssembly {
|
||||
relation: r.id,
|
||||
instance: prefix.to_vec(),
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if relations.is_empty()
|
||||
&& children.iter().all(|id| {
|
||||
matches!(
|
||||
self.index.project.elements[id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
{
|
||||
for pair in children.windows(2) {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(self.address(prefix, pair[0]), true),
|
||||
target: self.vertex(self.address(prefix, pair[1]), false),
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::LegacyOrder,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
// Intrinsic boundaries are determined before any enclosing caller links.
|
||||
let edges = &self.result.precedence[start..];
|
||||
let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect();
|
||||
let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect();
|
||||
let entries = children
|
||||
.iter()
|
||||
.map(|id| self.vertex(self.address(prefix, *id), false))
|
||||
.filter(|v| !targets.contains(v))
|
||||
.collect();
|
||||
let exits = children
|
||||
.iter()
|
||||
.map(|id| self.vertex(self.address(prefix, *id), true))
|
||||
.filter(|v| !sources.contains(v))
|
||||
.collect();
|
||||
active.remove(&sequence);
|
||||
(entries, exits)
|
||||
}
|
||||
fn cardinality(&mut self) {
|
||||
// Index authored incidence once; synthetic boundaries never count as controls.
|
||||
let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for edge in &self.result.precedence {
|
||||
if edge.kind == ContextEdgeKind::Authored {
|
||||
incoming.entry(&edge.target.address).or_default().push(edge);
|
||||
outgoing.entry(&edge.source.address).or_default().push(edge);
|
||||
}
|
||||
}
|
||||
for address in self.result.nodes.keys() {
|
||||
let ins = incoming.get(address).cloned().unwrap_or_default();
|
||||
let outs = outgoing.get(address).cloned().unwrap_or_default();
|
||||
let before = ins.len();
|
||||
let after = outs.len();
|
||||
let control = match self.index.project.elements[&address.endpoint.occurrence].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control { kind },
|
||||
..
|
||||
}) => Some(*kind),
|
||||
_ => None,
|
||||
};
|
||||
let issue = match control {
|
||||
Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) }
|
||||
None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())),
|
||||
};
|
||||
if let Some((code, message)) = issue {
|
||||
self.result.notices.push(ContextNotice {
|
||||
address: address.clone(),
|
||||
code,
|
||||
message,
|
||||
relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(),
|
||||
});
|
||||
}
|
||||
if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) {
|
||||
for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) {
|
||||
self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn connectivity(&mut self) {
|
||||
let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for edge in &self.result.precedence {
|
||||
forward
|
||||
.entry(edge.source.clone())
|
||||
.or_default()
|
||||
.push(edge.target.clone());
|
||||
reverse
|
||||
.entry(edge.target.clone())
|
||||
.or_default()
|
||||
.push(edge.source.clone());
|
||||
}
|
||||
let visit = |starts: &[ContextVertex],
|
||||
graph: &BTreeMap<ContextVertex, Vec<ContextVertex>>| {
|
||||
let mut pending = starts.to_vec();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(vertex) = pending.pop() {
|
||||
if seen.insert(vertex.clone()) {
|
||||
pending.extend(graph.get(&vertex).into_iter().flatten().cloned());
|
||||
}
|
||||
}
|
||||
seen
|
||||
};
|
||||
let from_start = visit(&self.result.entries, &forward);
|
||||
let to_end = visit(&self.result.exits, &reverse);
|
||||
for address in self.result.nodes.keys() {
|
||||
let entry = self.vertex(address.clone(), false);
|
||||
let exit = self.vertex(address.clone(), true);
|
||||
for (missing, code, message) in [
|
||||
(
|
||||
!from_start.contains(&entry),
|
||||
"unreachable_contribution",
|
||||
"No path from a structural caller entry reaches this contribution in this context.",
|
||||
),
|
||||
(
|
||||
!to_end.contains(&exit),
|
||||
"nonterminating_contribution",
|
||||
"No structural caller exit is reachable from this contribution in this context; review the intended loop.",
|
||||
),
|
||||
(
|
||||
self.result.nodes.len() > 1
|
||||
&& !reverse.contains_key(&entry)
|
||||
&& !forward.contains_key(&exit),
|
||||
"isolated_contribution",
|
||||
"This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.",
|
||||
),
|
||||
] {
|
||||
if missing {
|
||||
self.result.notices.push(ContextNotice {
|
||||
address: address.clone(),
|
||||
code,
|
||||
message: message.into(),
|
||||
relations: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::*;
|
||||
|
||||
fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn repeated(references: usize, links: usize) -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let root = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None)
|
||||
.unwrap();
|
||||
let procedure = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None)
|
||||
.unwrap();
|
||||
let mut leaves = Vec::new();
|
||||
for name in ["A", "B"] {
|
||||
let action = p
|
||||
.add_element(Kind::Function, Layer::Sa, name, None)
|
||||
.unwrap();
|
||||
leaves.push(occurrence(
|
||||
&mut p,
|
||||
procedure,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(action),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in 0..links {
|
||||
p.relate(
|
||||
RelationKind::SequenceLink,
|
||||
"Authored A to B",
|
||||
leaves[0],
|
||||
leaves[1],
|
||||
);
|
||||
}
|
||||
for _ in 0..references {
|
||||
occurrence(
|
||||
&mut p,
|
||||
root,
|
||||
"Reuse",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(procedure),
|
||||
},
|
||||
);
|
||||
}
|
||||
(p, root, procedure)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() {
|
||||
// Only ~3,000 possible contribution nodes, but a million authored edge
|
||||
// instances. The edge budget must stop actual work before later references.
|
||||
let (p, root, _) = repeated(1_000, 1_000);
|
||||
let limits = AnalysisLimits::default();
|
||||
let index = TopologyIndex::with_cache_capacity(&p, 1);
|
||||
let (first, attempts) = index.analyze_counted(root, limits);
|
||||
assert_eq!(attempts, limits.edges);
|
||||
assert_eq!(first.precedence.len(), limits.edges);
|
||||
assert!(!first.complete);
|
||||
assert!(first.entries.is_empty() && first.exits.is_empty());
|
||||
assert!(
|
||||
first.nodes.len() < 1_000,
|
||||
"later caller references must not be expanded"
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
first
|
||||
.notices
|
||||
.iter()
|
||||
.all(|n| n.code == "context_analysis_limit")
|
||||
);
|
||||
let (warm, warm_attempts) = index.analyze_counted(root, limits);
|
||||
assert_eq!(warm_attempts, attempts);
|
||||
assert_eq!(warm.nodes, first.nodes);
|
||||
assert_eq!(warm.precedence, first.precedence);
|
||||
assert_eq!(warm.notices, first.notices);
|
||||
drop(warm);
|
||||
let (uncached, uncached_attempts) =
|
||||
TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits);
|
||||
assert_eq!(uncached_attempts, attempts);
|
||||
assert_eq!(uncached.precedence, first.precedence);
|
||||
assert_eq!(uncached.notices, first.notices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_authored_edges_consume_work_budget_without_stored_output() {
|
||||
let (mut p, root, _) = repeated(20, 100);
|
||||
let missing = Id::nil();
|
||||
for relation in p.relations.values_mut() {
|
||||
relation.body.set_endpoints(relation.source(), missing);
|
||||
}
|
||||
let (result, attempts) = TopologyIndex::new(&p).analyze_counted(
|
||||
root,
|
||||
AnalysisLimits {
|
||||
nodes: 1_000,
|
||||
edges: 17,
|
||||
},
|
||||
);
|
||||
assert_eq!(attempts, 17);
|
||||
assert!(result.precedence.is_empty());
|
||||
assert_eq!(result.nodes.len(), 3);
|
||||
assert!(!result.complete);
|
||||
assert!(result.entries.is_empty() && result.exits.is_empty());
|
||||
assert_eq!(
|
||||
result
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() {
|
||||
let (p, root, _) = repeated(1, 2);
|
||||
let index = TopologyIndex::new(&p);
|
||||
let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 });
|
||||
assert!(complete.complete);
|
||||
assert_eq!(count, 4); // two authored edges plus entry and exit connectors
|
||||
assert_eq!(
|
||||
complete
|
||||
.precedence
|
||||
.iter()
|
||||
.filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
for limits in [
|
||||
AnalysisLimits { nodes: 3, edges: 3 },
|
||||
AnalysisLimits {
|
||||
nodes: 1,
|
||||
edges: 10,
|
||||
},
|
||||
] {
|
||||
let (partial, count) = index.analyze_counted(root, limits);
|
||||
assert!(!partial.complete);
|
||||
assert!(partial.entries.is_empty() && partial.exits.is_empty());
|
||||
assert!(count <= limits.edges);
|
||||
assert_eq!(
|
||||
partial
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,12 @@ pub struct CommunicationEndpointDecision {
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationOmission {
|
||||
pub source: Id,
|
||||
pub rationale: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionRequest {
|
||||
pub direction: TransitionDirection,
|
||||
pub sources: Vec<Id>,
|
||||
@@ -91,6 +97,8 @@ pub struct TransitionRequest {
|
||||
pub destination_diagram: Option<Id>,
|
||||
#[serde(default)]
|
||||
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub operation_omissions: Vec<OperationOmission>,
|
||||
}
|
||||
impl TransitionRequest {
|
||||
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
|
||||
@@ -103,6 +111,7 @@ impl TransitionRequest {
|
||||
resolutions: vec![],
|
||||
destination_diagram: None,
|
||||
communication_endpoints: vec![],
|
||||
operation_omissions: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
| RelationBody::MissionInvolvement { .. }
|
||||
| RelationBody::MissionCapability { .. }
|
||||
| RelationBody::SequenceLink { .. }
|
||||
| RelationBody::ContextualLink { .. }
|
||||
| RelationBody::ChainInvolvement { .. }
|
||||
| RelationBody::BoundaryExchange { .. }
|
||||
| RelationBody::InterfaceProvision { .. }
|
||||
@@ -500,7 +510,16 @@ pub fn plan_transition(
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
let mut dependencies = e.clone();
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = dependencies.shared_mut()
|
||||
{
|
||||
*operation = None;
|
||||
}
|
||||
selected.extend(crate::references::element_references(&dependencies));
|
||||
// Allocations are owned by interfaces. Independent data
|
||||
// associations travel only when both endpoint types are
|
||||
// already required; they do not expand the dictionary.
|
||||
@@ -523,12 +542,13 @@ pub fn plan_transition(
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
selected.extend(project.relations.values().filter_map(|relation| {
|
||||
(relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id))
|
||||
(relation.contextual_owner() == Some(id)
|
||||
|| (relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id)))
|
||||
.then_some(relation.id)
|
||||
}));
|
||||
}
|
||||
@@ -594,6 +614,67 @@ pub fn plan_transition(
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
let mut operation_omissions = BTreeSet::new();
|
||||
for decision in &request.operation_omissions {
|
||||
if request.direction != TransitionDirection::SaToOa
|
||||
|| !selected.contains(&decision.source)
|
||||
|| !matches!(
|
||||
project
|
||||
.elements
|
||||
.get(&decision.source)
|
||||
.and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
|| decision.rationale.trim().is_empty()
|
||||
|| !validation::valid_text(&decision.rationale)
|
||||
|| !operation_omissions.insert(decision.source)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(decision.source),
|
||||
None,
|
||||
None,
|
||||
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
|
||||
);
|
||||
} else {
|
||||
plan.warnings.push(format!(
|
||||
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
|
||||
project.named(decision.source),
|
||||
decision.source,
|
||||
decision.rationale
|
||||
));
|
||||
}
|
||||
}
|
||||
if request.direction == TransitionDirection::SaToOa {
|
||||
for source in &selected {
|
||||
if matches!(
|
||||
project.elements.get(source).and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) && !operation_omissions.contains(source)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::new();
|
||||
for mapping in &request.mappings {
|
||||
if choices.insert(mapping.source, mapping).is_some() {
|
||||
@@ -787,6 +868,15 @@ pub fn plan_transition(
|
||||
let mut created = e.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& operation_omissions.contains(source)
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = created.shared_mut()
|
||||
{
|
||||
*operation = None;
|
||||
}
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
|
||||
@@ -182,6 +182,7 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let coverage = coverage::Index::new(p);
|
||||
let topology_index = crate::topology_context::TopologyIndex::new(p);
|
||||
if p.schema_version != SCHEMA_VERSION {
|
||||
error(
|
||||
&mut out,
|
||||
@@ -383,7 +384,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
"Add ordered occurrences.",
|
||||
);
|
||||
}
|
||||
validate_sequence(p, e, steps, &mut out);
|
||||
validate_sequence(p, e, steps, &topology_index, &mut out);
|
||||
}
|
||||
if let Some(shared) = e.shared() {
|
||||
match shared {
|
||||
@@ -600,7 +601,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
validate_relation(p, r, &topology_index, &mut out);
|
||||
crate::data::validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
@@ -950,10 +951,57 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
|
||||
fn validate_sequence(
|
||||
p: &Project,
|
||||
e: &Element,
|
||||
steps: &[Id],
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return;
|
||||
}
|
||||
if topology.has_contextual_links(e.id) {
|
||||
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
|
||||
for notice in analysis.notices {
|
||||
let breadcrumb = notice
|
||||
.address
|
||||
.endpoint
|
||||
.references
|
||||
.iter()
|
||||
.chain(std::iter::once(¬ice.address.endpoint.occurrence))
|
||||
.map(|id| format!("{} ({id})", p.named(*id)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
notice.code,
|
||||
"steps",
|
||||
format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message),
|
||||
);
|
||||
}
|
||||
if analysis.complete {
|
||||
for (code, label, count) in [
|
||||
("sequence_starts", "start", analysis.entries.len()),
|
||||
("sequence_ends", "end", analysis.exits.len()),
|
||||
] {
|
||||
if count != 1 {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
code,
|
||||
"steps",
|
||||
format!(
|
||||
"Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.",
|
||||
e.name
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let links: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
@@ -1554,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return;
|
||||
@@ -1766,6 +1815,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
let operation = crate::operations::resolve(p, e.id);
|
||||
let signature = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Selected(selected)) => {
|
||||
if selected.transmission == TransmissionProtocol::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"operation_transmission_unspecified",
|
||||
"operation",
|
||||
"The selected operation transmission protocol is unspecified.",
|
||||
);
|
||||
}
|
||||
Some(crate::operations::signature_relation(selected))
|
||||
}
|
||||
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
|
||||
warning(out, e.id, "operation_unresolved", "operation", reason);
|
||||
None
|
||||
}
|
||||
Err(problem) => {
|
||||
error(out, e.id, problem.code, "operation", &problem.message);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let value_relation = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Ordinary) => relation,
|
||||
_ => signature.as_ref(),
|
||||
};
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
@@ -1811,10 +1888,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if matches!(
|
||||
operation,
|
||||
Ok(crate::operations::OperationResolution::Unresolved { .. })
|
||||
) && let Some(owner) = value
|
||||
.path
|
||||
.first()
|
||||
.or(Some(&value.field))
|
||||
.and_then(|id| p.elements.get(id))
|
||||
.and_then(|field| field.parent)
|
||||
{
|
||||
let path_context = Relation {
|
||||
id: e.id,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: e.id,
|
||||
target: e.id,
|
||||
items: vec![owner],
|
||||
},
|
||||
};
|
||||
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
|
||||
{
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_path",
|
||||
"values.path",
|
||||
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(r) = value_relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Asynchronous if signature.is_some() => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
@@ -2254,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
false
|
||||
}
|
||||
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
fn validate_relation(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
{
|
||||
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
|
||||
error(out, r.id, problem.code, "body", problem.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
@@ -2632,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
if kind == RelationKind::StateTransition {
|
||||
for endpoint in [source, target] {
|
||||
@@ -2662,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
|
||||
},
|
||||
};
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
!issues.iter().any(|i| i.severity == Severity::Error)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Persisted view intent and pure visibility decisions over actual model identities.
|
||||
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
|
||||
//! set unions/reachability once; testing an edge never walks the model.
|
||||
use crate::{Id, Kind, Layer, OccurrenceBody, Project, RelationKind, SharedBody};
|
||||
use crate::{
|
||||
ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind,
|
||||
SharedBody,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -401,6 +404,25 @@ impl BehaviorIndex {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let topology = crate::topology_context::TopologyIndex::new(p);
|
||||
for relation in p.relations.values() {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose: purpose @ ContextualLinkPurpose::Exchange { exchange },
|
||||
} = &relation.body
|
||||
&& topology
|
||||
.validate_link(*owner, source, target, purpose)
|
||||
.is_ok()
|
||||
{
|
||||
let facts = index.facts.entry(*owner).or_default();
|
||||
facts.exchanges.insert(*exchange);
|
||||
if let Some(exchange) = p.relations.get(exchange) {
|
||||
facts.work.extend([exchange.source(), exchange.target()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
|
||||
|
||||
fn exchange(id: Id) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
Reference in New Issue
Block a user