diff --git a/crates/oa-core/src/ports.rs b/crates/oa-core/src/ports.rs index fc8cca80..b013ca26 100644 --- a/crates/oa-core/src/ports.rs +++ b/crates/oa-core/src/ports.rs @@ -847,6 +847,11 @@ fn legacy_allows(e: &Element, outgoing: bool) -> bool { matches!(e.shared(), Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, .. }) }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out }) } fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mut Vec) { + // Every carried-item constraint is a subset check. An empty exchange cannot + // violate one, so endpoint feasibility need not scan all interface roles. + if r.body.items().is_empty() { + return; + } match port.shared() { Some(SharedBody::Port { contract: PortContract::FunctionInput { incoming_items }, @@ -1060,34 +1065,6 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec { let (Some(component), Some(function)) = @@ -1167,6 +1144,49 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec {} } } +/// Whole-model advisory checks. Missing peer declarations are warnings, so they +/// must not make endpoint feasibility scan the relation graph per candidate. +pub(crate) fn validate_boundary_interface_requirements( + p: &Project, + r: &Relation, + out: &mut Vec, +) { + let RelationBody::BoundaryExchange { source, target, .. } = &r.body else { + return; + }; + let (Some(a), Some(b)) = (p.elements.get(source), p.elements.get(target)) else { + return; + }; + for (port, peer) in [(a, b), (b, a)] { + if !matches!( + port_contract(port), + Some(PortContract::Component { + kind: ComponentPortKind::Standard, + .. + }) + ) { + continue; + } + for required in p.relations.values().filter(|role| { + role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id + }) { + if !p.relations.values().any(|role| { + role.kind() == RelationKind::InterfaceProvision + && role.source() == peer.id + && role.target() == required.target() + }) { + warning( + out, + required.id, + "unsatisfied_interface_requirement", + "target", + "The connected partner has not declared this required interface as provided.", + ); + } + } + } +} + pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec) { let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target())) else { diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 278240a5..80eb720b 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -643,6 +643,7 @@ pub fn validate(p: &Project) -> Vec { text(&mut out, r.id, "name", &r.name); text(&mut out, r.id, "description", &r.description); validate_relation(p, r, &mut out); + crate::ports::validate_boundary_interface_requirements(p, r, &mut out); validate_relation_context(p, r, &mut out); } for (key, d) in &p.diagrams { @@ -2010,7 +2011,10 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { let (source, target) = r.body.endpoints(); for id in crate::references::relation_references(r) { - if !p.contains(id) { + // Most relation references name elements. Avoid constructing all model + // identities per endpoint while retaining the general lookup semantics + // for other record kinds and malformed map keys. + if !p.elements.get(&id).is_some_and(|element| element.id == id) && !p.contains(id) { error( out, r.id,