Integrate typed ports and schema 3 migration
# Conflicts: # crates/oa-core/src/projection.rs
This commit is contained in:
@@ -34,11 +34,33 @@ fn occurrence(
|
||||
id
|
||||
}
|
||||
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(owner));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
direction,
|
||||
interface: Some(interface),
|
||||
let items = match p.elements[&interface].shared() {
|
||||
Some(SharedBody::Interface { items }) => items.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let contract = if p.elements[&owner].kind() == Kind::Function {
|
||||
if direction == PortDirection::Out {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: items,
|
||||
}
|
||||
} else {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: items,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: match direction {
|
||||
PortDirection::In => ComponentPortOrientation::In,
|
||||
PortDirection::Out => ComponentPortOrientation::Out,
|
||||
PortDirection::InOut => ComponentPortOrientation::InOut,
|
||||
},
|
||||
}
|
||||
};
|
||||
let id = Id::new_v4();
|
||||
let element = crate::ports::make_port(p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, element);
|
||||
id
|
||||
}
|
||||
fn diagram(
|
||||
@@ -369,8 +391,8 @@ pub fn coastal_rescue() -> Project {
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: transmit,
|
||||
target: coordinate,
|
||||
source_port: Some(function_out),
|
||||
target_port: Some(function_in),
|
||||
source_port: function_out,
|
||||
target_port: function_in,
|
||||
items: vec![information],
|
||||
};
|
||||
let boundary = p.relate(
|
||||
@@ -384,6 +406,18 @@ pub fn coastal_rescue() -> Project {
|
||||
{
|
||||
items.push(information);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Vessel output represents report output",
|
||||
vessel_port,
|
||||
function_out,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"System input represents assessment input",
|
||||
system_port,
|
||||
function_in,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::ExchangeAllocation,
|
||||
"Report carried by connection",
|
||||
@@ -393,13 +427,13 @@ pub fn coastal_rescue() -> Project {
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Reporting service provided",
|
||||
actor,
|
||||
vessel_port,
|
||||
interface,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Reporting service required",
|
||||
system,
|
||||
system_port,
|
||||
interface,
|
||||
);
|
||||
let sa_occurrence = occurrence(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1 migration.
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
use serde_json::{Map, Value};
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
@@ -317,6 +319,8 @@ fn reference_field(key: &str) -> bool {
|
||||
| "branches"
|
||||
| "Named"
|
||||
| "items"
|
||||
| "incoming_items"
|
||||
| "outgoing_items"
|
||||
| "triggers"
|
||||
| "effects"
|
||||
| "interface"
|
||||
@@ -351,6 +355,8 @@ fn enum_field(key: &str) -> bool {
|
||||
key,
|
||||
"kind"
|
||||
| "direction"
|
||||
| "orientation"
|
||||
| "contract"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "relation"
|
||||
@@ -364,13 +370,24 @@ fn enum_field(key: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse V2, or explicitly migrate a valid V1 source candidate. Parsing never
|
||||
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
|
||||
/// adopts a model and never overwrites an editor draft.
|
||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_limited(source, ParseLimits::UNLIMITED)
|
||||
}
|
||||
|
||||
pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_with_report_limited(source, limits).map(|decoded| decoded.project)
|
||||
}
|
||||
pub fn parse_with_report(
|
||||
source: &str,
|
||||
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
|
||||
parse_with_report_limited(source, ParseLimits::UNLIMITED)
|
||||
}
|
||||
pub fn parse_with_report_limited(
|
||||
source: &str,
|
||||
limits: ParseLimits,
|
||||
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
|
||||
let parsed = parse_document(source, limits)?;
|
||||
let errors: Vec<_> = parsed
|
||||
.diagnostics(source)
|
||||
@@ -378,7 +395,10 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
|
||||
.filter(|d| d.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
if errors.is_empty() {
|
||||
Ok(parsed.project)
|
||||
Ok(crate::persistence::DecodeResult {
|
||||
project: parsed.project,
|
||||
migration: parsed.migration,
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
@@ -386,6 +406,7 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
|
||||
|
||||
struct ParsedDocument {
|
||||
project: Project,
|
||||
migration: Option<crate::persistence::MigrationReport>,
|
||||
locations: BTreeMap<Id, usize>,
|
||||
fields: BTreeMap<(Id, String), usize>,
|
||||
}
|
||||
@@ -460,22 +481,31 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})?;
|
||||
let project = crate::persistence::migrate_v1_limited(old, limits).map_err(|message| {
|
||||
vec![diagnostic(
|
||||
source,
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: 0,
|
||||
message,
|
||||
},
|
||||
)]
|
||||
})?;
|
||||
let decoded =
|
||||
crate::persistence::migrate_v1_with_report_limited(old, limits).map_err(|error| {
|
||||
vec![diagnostic(
|
||||
source,
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"invalid_source"
|
||||
},
|
||||
offset: 0,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)]
|
||||
})?;
|
||||
return Ok(ParsedDocument {
|
||||
project,
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
locations: BTreeMap::new(),
|
||||
fields: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
|
||||
return v2_source::parse(source, tokens, limits);
|
||||
}
|
||||
Parser {
|
||||
source,
|
||||
tokens,
|
||||
@@ -510,7 +540,7 @@ pub fn format(project: &Project) -> String {
|
||||
.map(|(id, a)| (id, a.to_owned()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut out = std::format!(
|
||||
"arcadia 2;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
@@ -1000,8 +1030,8 @@ impl Parser<'_> {
|
||||
}
|
||||
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
||||
self.take("arcadia")?;
|
||||
if self.text() != "2" {
|
||||
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
|
||||
if self.text() != "3" {
|
||||
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
|
||||
}
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
@@ -1116,7 +1146,22 @@ impl Parser<'_> {
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let parent =
|
||||
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
||||
let mut initial = ElementBody::new(kind, layer).unwrap();
|
||||
let mut initial = if kind == Kind::Port {
|
||||
if !fields.contains_key("contract") {
|
||||
return Err(conversion_error(
|
||||
"Missing required field 'contract'.".into(),
|
||||
));
|
||||
}
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: None,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ElementBody::new(kind, layer).unwrap()
|
||||
};
|
||||
// Schema-2 sources predate explicit scenario viewpoints.
|
||||
// Preserve their mixed lifelines instead of reinterpreting
|
||||
// old source using a new graphical factory's typed default.
|
||||
@@ -1151,7 +1196,12 @@ impl Parser<'_> {
|
||||
DeclKind::Relation(kind) => {
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
for required in ["source", "target"] {
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
} else {
|
||||
&["source", "target"]
|
||||
};
|
||||
for &required in required {
|
||||
if !fields.contains_key(required) {
|
||||
return Err(conversion_error(std::format!(
|
||||
"Missing required field '{required}'."
|
||||
@@ -1246,6 +1296,7 @@ impl Parser<'_> {
|
||||
})?;
|
||||
Ok(ParsedDocument {
|
||||
project,
|
||||
migration: None,
|
||||
locations,
|
||||
fields: field_locations,
|
||||
})
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod persistence;
|
||||
pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod session;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
mod state_validation;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Frozen version 2 reader
|
||||
|
||||
`model.rs`, `validation.rs`, `state_validation.rs`, `references.rs`, the drift
|
||||
comparison in `transitions.rs`, and the typed declaration reader in `source.rs`
|
||||
were captured from commit `c20a207` before the writable model changed to schema 3.
|
||||
The modules are private; they are not another authoring model.
|
||||
|
||||
Imports point at the frozen modules, and the unused demo factory is excluded.
|
||||
The declaration reader shares lexical/AST mechanics with the current language,
|
||||
but constructs and validates the frozen DTO, preserves located old-contract
|
||||
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
|
||||
generic port/optional FE endpoint contracts therefore remain explicit.
|
||||
|
||||
`adapter.rs` budgets generated data, validates V2, transforms only the changed
|
||||
payloads, and validates schema 3. Serialization between the two typed DTOs copies
|
||||
unchanged fields; original input bytes are never deserialized into the new
|
||||
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
|
||||
|
||||
Do not update these frozen shapes when extending the current model. Add another
|
||||
versioned migration when the persistent contract changes again. Golden V2
|
||||
source/JSON fixtures remain unchanged.
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{
|
||||
DecodeResult, FileError, GeneratedEndpoint, MigrationReport, OrientationDefault, PreservedPort,
|
||||
};
|
||||
use crate::*;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Validate the real V2 graph, then explicitly transform its changed payloads.
|
||||
/// The JSON bridge below copies unchanged DTO fields; it never reads old bytes
|
||||
/// as a current Project and never lets current constructors supply old defaults.
|
||||
pub(crate) fn migrate(
|
||||
old: super::model::Project,
|
||||
limits: ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
super::check_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
let mut generated = 0usize;
|
||||
let mut additional_references = 0usize;
|
||||
for r in old.relations.values() {
|
||||
if let super::model::RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &r.body
|
||||
{
|
||||
let missing = usize::from(source_port.is_none()) + usize::from(target_port.is_none());
|
||||
generated = generated.saturating_add(missing);
|
||||
additional_references = additional_references
|
||||
.saturating_add(missing.saturating_mul(items.len().saturating_add(2)));
|
||||
}
|
||||
}
|
||||
let (graph, records, references) = super::counts(&old);
|
||||
limits
|
||||
.check_counts(
|
||||
graph.saturating_add(generated),
|
||||
records.saturating_add(generated),
|
||||
references.saturating_add(additional_references),
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
if let Some(issue) = super::validation::validate(&old)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == super::validation::Severity::Error)
|
||||
{
|
||||
let message = format!(
|
||||
"V2 {} at {}.{}: {}",
|
||||
issue.code,
|
||||
issue
|
||||
.element
|
||||
.map_or_else(|| "project".into(), |id| id.to_string()),
|
||||
issue.field.as_deref().unwrap_or_default(),
|
||||
issue.message
|
||||
);
|
||||
return Err(FileError::Model {
|
||||
message,
|
||||
issues: vec![validation::Issue {
|
||||
severity: validation::Severity::Error,
|
||||
element: issue.element,
|
||||
code: issue.code,
|
||||
field: issue.field,
|
||||
message: issue.message,
|
||||
}],
|
||||
});
|
||||
}
|
||||
let mut report = MigrationReport {
|
||||
from_schema: 2,
|
||||
to_schema: 3,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
};
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
value["schema_version"] = Value::from(SCHEMA_VERSION);
|
||||
for element in old.elements.values() {
|
||||
if let Some(super::model::SharedBody::Port {
|
||||
direction,
|
||||
interface,
|
||||
}) = element.shared()
|
||||
{
|
||||
let layer = match element.layer() {
|
||||
super::model::Layer::Oa => "Oa",
|
||||
super::model::Layer::Sa => "Sa",
|
||||
};
|
||||
value["elements"][element.id.to_string()]["body"][layer]["Shared"]["Port"] = serde_json::json!({"contract":"Legacy", "legacy":{"direction":direction,"interface":interface}});
|
||||
let required_resolution = if element.layer() == super::model::Layer::Oa {
|
||||
"Keep this OA Legacy contract unresolved or review an explicit SA counterpart; typed ports are SA-only."
|
||||
} else {
|
||||
"Review the preserved generic direction/interface contract before selecting a typed SA port contract."
|
||||
};
|
||||
report.preserved_ports.push(PreservedPort {
|
||||
id: element.id,
|
||||
alias: element.alias.clone(),
|
||||
required_resolution: required_resolution.into(),
|
||||
});
|
||||
report.required_resolutions.push(element.id);
|
||||
}
|
||||
}
|
||||
let mut used_ids: BTreeSet<_> = old.identities().into_iter().map(|(id, _)| id).collect();
|
||||
// Use the existing alias convention against the old graph plus each newly
|
||||
// planned endpoint, so cross-category alias collisions are deterministic.
|
||||
let mut alias_project = old.clone();
|
||||
for relation in old.relations.values() {
|
||||
match &relation.body {
|
||||
super::model::RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
} => {
|
||||
for (side, owner, existing, output) in [
|
||||
("source", *source, source_port, true),
|
||||
("target", *target, target_port, false),
|
||||
] {
|
||||
if existing.is_some() {
|
||||
continue;
|
||||
}
|
||||
let id = Id::new_v5(
|
||||
&old.id,
|
||||
format!("v2-fe-port:{}:{side}", relation.id).as_bytes(),
|
||||
);
|
||||
if !used_ids.insert(id) {
|
||||
let message = format!(
|
||||
"V2 migration identity_collision at {}.{}_port: deterministic endpoint {} already belongs to another object.",
|
||||
relation.id, side, id
|
||||
);
|
||||
return Err(FileError::Model {
|
||||
message: message.clone(),
|
||||
issues: vec![validation::Issue {
|
||||
severity: validation::Severity::Error,
|
||||
element: Some(relation.id),
|
||||
code: "identity_collision".into(),
|
||||
field: Some(format!("{side}_port")),
|
||||
message,
|
||||
}],
|
||||
});
|
||||
}
|
||||
let role = if output { "output" } else { "input" };
|
||||
let name = if relation.name.trim().is_empty() {
|
||||
format!("Function {role}")
|
||||
} else {
|
||||
format!("{} {role}", relation.name)
|
||||
};
|
||||
let alias = alias_project.fresh_alias(&name, "port", id);
|
||||
// Only identity/name/alias enter this planning-only V2 map.
|
||||
alias_project.elements.insert(
|
||||
id,
|
||||
super::model::Element {
|
||||
id,
|
||||
alias: alias.clone(),
|
||||
name: name.clone(),
|
||||
description: String::new(),
|
||||
parent: Some(owner),
|
||||
body: super::model::ElementBody::Sa(super::model::SaBody::Function),
|
||||
},
|
||||
);
|
||||
let contract = if output {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: items.clone(),
|
||||
}
|
||||
} else {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: items.clone(),
|
||||
}
|
||||
};
|
||||
let element = Element {
|
||||
id,
|
||||
alias: alias.clone(),
|
||||
name,
|
||||
description: String::new(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract,
|
||||
legacy: None,
|
||||
},
|
||||
),
|
||||
};
|
||||
value["elements"]
|
||||
.as_object_mut()
|
||||
.expect("V2 elements map")
|
||||
.insert(id.to_string(), serde_json::to_value(element)?);
|
||||
value["relations"][relation.id.to_string()]["body"]["FunctionalExchange"]
|
||||
[format!("{side}_port")] = serde_json::to_value(id)?;
|
||||
report.generated_endpoints.push(GeneratedEndpoint {
|
||||
exchange: relation.id,
|
||||
side: side.into(),
|
||||
port: id,
|
||||
owner,
|
||||
alias,
|
||||
});
|
||||
}
|
||||
}
|
||||
super::model::RelationBody::BoundaryExchange { .. } => {
|
||||
value["relations"][relation.id.to_string()]["body"]["BoundaryExchange"]["oriented"] =
|
||||
Value::Bool(true);
|
||||
report.orientation_defaults.push(OrientationDefault {
|
||||
exchange: relation.id,
|
||||
oriented: true,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let project: Project = serde_json::from_value(value)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::super::validate_project(&project)?;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Version-2 DTO and validation snapshot. Kept separate from the writable model
|
||||
//! so accepting an old file cannot accidentally inherit version-3 defaults.
|
||||
#![allow(dead_code)]
|
||||
pub(crate) mod adapter;
|
||||
pub(crate) mod model;
|
||||
mod references;
|
||||
mod state_validation;
|
||||
mod transitions;
|
||||
pub(crate) mod validation;
|
||||
|
||||
pub(crate) fn counts(project: &model::Project) -> (usize, usize, usize) {
|
||||
let graph = project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len());
|
||||
let records = graph.saturating_add(project.identities().len().saturating_sub(graph));
|
||||
// Keep the established ordered-reference budget for original records.
|
||||
// The adapter separately charges every reference it would generate.
|
||||
let references = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.fold(0usize, usize::saturating_add);
|
||||
(graph, records, references)
|
||||
}
|
||||
|
||||
pub(crate) fn check_limits(
|
||||
project: &model::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<(), String> {
|
||||
let (graph, records, references) = counts(project);
|
||||
limits.check_counts(graph, records, references)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
|
||||
use super::model::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Every live reference in a typed element, including structural ownership.
|
||||
pub fn element_references(e: &Element) -> Vec<Id> {
|
||||
let mut copy = e.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_element_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
pub fn relation_references(r: &Relation) -> Vec<Id> {
|
||||
let mut copy = r.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_relation_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
if let Some(parent) = &mut e.parent {
|
||||
visit(parent);
|
||||
}
|
||||
match &mut e.body {
|
||||
ElementBody::Oa(OaBody::Process { steps })
|
||||
| ElementBody::Sa(SaBody::FunctionalChain { steps }) => {
|
||||
for id in steps {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Interface { items } => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Port {
|
||||
interface: Some(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Scenario { context, steps, .. } => {
|
||||
if let Some(id) = context {
|
||||
visit(id);
|
||||
}
|
||||
for id in steps {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Lifeline {
|
||||
represented: Some(id),
|
||||
} => visit(id),
|
||||
SharedBody::Occurrence { body, .. } => match body {
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} => {
|
||||
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
for value in values {
|
||||
visit(&mut value.field);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
for id in [action, lifeline].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
if let Some(id) = sequence {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Control { .. } => {}
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
if let Some(id) = scenario {
|
||||
visit(id);
|
||||
}
|
||||
for binding in bindings {
|
||||
visit(&mut binding.referenced);
|
||||
visit(&mut binding.local);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::State { state, lifeline } => {
|
||||
for id in [state, lifeline].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Fragment { branches, .. } => {
|
||||
for id in branches {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Branch { occurrences, .. } => {
|
||||
for id in occurrences {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
SharedBody::Constraint { constrained, .. } => {
|
||||
for id in constrained {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
let (mut source, mut target) = r.body.endpoints();
|
||||
visit(&mut source);
|
||||
visit(&mut target);
|
||||
r.body.set_endpoints(source, target);
|
||||
match &mut r.body {
|
||||
RelationBody::StateTransition {
|
||||
triggers, effects, ..
|
||||
} => {
|
||||
for id in triggers.iter_mut().chain(effects) {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} => {
|
||||
for id in [source_port, target_port].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
for r in p.relations.values_mut() {
|
||||
visit_relation_refs(r, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
for n in d.nodes.values_mut() {
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
a.owner = new;
|
||||
}
|
||||
}
|
||||
for t in p.traces.values_mut() {
|
||||
if t.oa == old {
|
||||
t.oa = new;
|
||||
}
|
||||
if t.sa == old {
|
||||
t.sa = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
let mut removed = BTreeSet::from([id]);
|
||||
loop {
|
||||
let before = removed.len();
|
||||
for e in p.elements.values() {
|
||||
let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t));
|
||||
if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type {
|
||||
removed.insert(e.id);
|
||||
}
|
||||
if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r))
|
||||
{
|
||||
removed.insert(e.id);
|
||||
}
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
if removed.contains(&r.source()) || removed.contains(&r.target()) {
|
||||
removed.insert(r.id);
|
||||
}
|
||||
}
|
||||
for d in p.diagrams.values() {
|
||||
for n in d.nodes.values() {
|
||||
if removed.contains(&d.id) || removed.contains(&n.element) {
|
||||
removed.insert(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in p.traces.values() {
|
||||
if removed.contains(&t.oa) || removed.contains(&t.sa) {
|
||||
removed.insert(t.id);
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values() {
|
||||
if removed.contains(&a.owner) {
|
||||
removed.insert(a.id);
|
||||
}
|
||||
}
|
||||
if removed.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn delete_cascade(p: &mut Project, id: Id) {
|
||||
let removed = deletion_impact(p, id);
|
||||
let removed_names: std::collections::BTreeMap<_, _> = removed
|
||||
.iter()
|
||||
.map(|id| (*id, p.named(*id).to_string()))
|
||||
.collect();
|
||||
p.elements.retain(|id, _| !removed.contains(id));
|
||||
p.relations.retain(|id, _| !removed.contains(id));
|
||||
p.diagrams.retain(|id, _| !removed.contains(id));
|
||||
p.traces.retain(|id, _| !removed.contains(id));
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
*v = None;
|
||||
}
|
||||
};
|
||||
for e in p.elements.values_mut() {
|
||||
if let Some(steps) = e.steps_mut() {
|
||||
steps.retain(|id| !removed.contains(id));
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Port { interface, .. } => clear(interface),
|
||||
SharedBody::Scenario { context, .. } => clear(context),
|
||||
SharedBody::Lifeline { represented } => clear(represented),
|
||||
SharedBody::Constraint { constrained, .. } => {
|
||||
constrained.retain(|id| !removed.contains(id))
|
||||
}
|
||||
SharedBody::Occurrence { body, .. } => match body {
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} => {
|
||||
clear(exchange);
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
clear(reply_to);
|
||||
values.retain(|value| !removed.contains(&value.field));
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
clear(action);
|
||||
clear(lifeline);
|
||||
}
|
||||
OccurrenceBody::SequenceReference { sequence } => clear(sequence),
|
||||
OccurrenceBody::Control { .. } => {}
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
clear(scenario);
|
||||
bindings.retain(|binding| {
|
||||
!removed.contains(&binding.referenced)
|
||||
&& !removed.contains(&binding.local)
|
||||
});
|
||||
}
|
||||
OccurrenceBody::State { state, lifeline } => {
|
||||
clear(state);
|
||||
clear(lifeline);
|
||||
}
|
||||
OccurrenceBody::Fragment { branches, .. } => {
|
||||
branches.retain(|id| !removed.contains(id))
|
||||
}
|
||||
OccurrenceBody::Branch { .. } => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in p.relations.values_mut() {
|
||||
match &mut r.body {
|
||||
RelationBody::StateTransition {
|
||||
triggers,
|
||||
effects,
|
||||
trigger,
|
||||
..
|
||||
} => {
|
||||
let deleted: Vec<_> = triggers
|
||||
.iter()
|
||||
.filter(|id| removed.contains(id))
|
||||
.filter_map(|id| removed_names.get(id).cloned())
|
||||
.collect();
|
||||
triggers.retain(|id| !removed.contains(id));
|
||||
effects.retain(|id| !removed.contains(id));
|
||||
if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() {
|
||||
*trigger = format!(
|
||||
"Removed trigger: {}. Choose a replacement trigger.",
|
||||
deleted.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. } => {
|
||||
items.retain(|id| !removed.contains(id))
|
||||
}
|
||||
RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => {
|
||||
items.retain(|id| !removed.contains(id));
|
||||
clear(source_port);
|
||||
clear(target_port);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Removing an item or a containing payload field can disconnect a still-live
|
||||
// record field from an exchange. Its contextual annotation is no longer valid.
|
||||
let allowed: std::collections::BTreeMap<_, _> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
}) => p
|
||||
.relations
|
||||
.get(id)
|
||||
.map(|r| (e.id, super::validation::carried_fields(p, r))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, fields) in allowed {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
|
||||
{
|
||||
values.retain(|value| fields.contains(&value.field));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Frozen schema-2 declaration reader from c20a207, with explicit migration and located diagnostics.
|
||||
use super::{
|
||||
Ast, Error, Lexeme, ParseLimits, TokenClass, merge_fields, payload, replace_payload,
|
||||
take_optional_id, take_required_id, take_string,
|
||||
};
|
||||
use crate::persistence::v2::model::*;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum DeclKind {
|
||||
Element(Kind, Layer),
|
||||
Relation(RelationKind),
|
||||
Diagram,
|
||||
Node,
|
||||
Artifact,
|
||||
Trace,
|
||||
}
|
||||
fn snake(value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, c) in value.chars().enumerate() {
|
||||
if c.is_ascii_uppercase() {
|
||||
if i > 0 {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(c)
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn element_word(kind: Kind, layer: Layer) -> String {
|
||||
let stem = match kind {
|
||||
Kind::SystemActor => "actor".into(),
|
||||
Kind::SystemCapability => "capability".into(),
|
||||
Kind::OperationalMission => "mission".into(),
|
||||
_ => snake(serde_json::to_value(kind).unwrap().as_str().unwrap()),
|
||||
};
|
||||
std::format!("{}_{stem}", if layer == Layer::Oa { "oa" } else { "sa" })
|
||||
}
|
||||
fn relation_word(kind: RelationKind) -> String {
|
||||
snake(serde_json::to_value(kind).unwrap().as_str().unwrap())
|
||||
}
|
||||
fn declaration_kind(word: &str) -> Option<DeclKind> {
|
||||
static KINDS: std::sync::OnceLock<BTreeMap<String, DeclKind>> = std::sync::OnceLock::new();
|
||||
KINDS
|
||||
.get_or_init(|| {
|
||||
let mut kinds = BTreeMap::from([
|
||||
("diagram".into(), DeclKind::Diagram),
|
||||
("node".into(), DeclKind::Node),
|
||||
("artifact".into(), DeclKind::Artifact),
|
||||
("trace".into(), DeclKind::Trace),
|
||||
]);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for kind in Kind::ALL {
|
||||
if kind.supports(layer) {
|
||||
kinds.insert(element_word(kind, layer), DeclKind::Element(kind, layer));
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind in RelationKind::ALL {
|
||||
kinds.insert(relation_word(kind), DeclKind::Relation(kind));
|
||||
}
|
||||
kinds
|
||||
})
|
||||
.get(word)
|
||||
.copied()
|
||||
}
|
||||
struct Declaration {
|
||||
kind: DeclKind,
|
||||
alias: String,
|
||||
name: String,
|
||||
id: Id,
|
||||
fields: BTreeMap<String, Ast>,
|
||||
offset: usize,
|
||||
}
|
||||
struct Parser2<'a> {
|
||||
source: &'a str,
|
||||
tokens: Vec<Lexeme>,
|
||||
cursor: usize,
|
||||
value_depth: usize,
|
||||
limits: ParseLimits,
|
||||
semantic_error: Option<crate::validation::Issue>,
|
||||
}
|
||||
impl Parser2<'_> {
|
||||
fn current(&self) -> Lexeme {
|
||||
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
||||
}
|
||||
fn text(&self) -> &str {
|
||||
self.current().text(self.source)
|
||||
}
|
||||
fn error(&self, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: self.current().span.start,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn take(&mut self, word: &str) -> Result<(), Error> {
|
||||
if self.text() != word {
|
||||
return Err(self.error(std::format!("Expected '{word}'.")));
|
||||
}
|
||||
self.cursor += 1;
|
||||
Ok(())
|
||||
}
|
||||
fn quoted(&mut self) -> Result<String, Error> {
|
||||
if self.current().class != TokenClass::String {
|
||||
return Err(self.error("Expected a double-quoted string."));
|
||||
}
|
||||
let value = serde_json::from_str(self.text()).unwrap();
|
||||
self.cursor += 1;
|
||||
Ok(value)
|
||||
}
|
||||
fn identifier(&mut self) -> Result<String, Error> {
|
||||
let value = self.text().to_owned();
|
||||
if !valid_alias(&value) {
|
||||
return Err(self.error(std::format!("Invalid or reserved alias '{value}'.")));
|
||||
}
|
||||
self.cursor += 1;
|
||||
Ok(value)
|
||||
}
|
||||
fn optional_id(&mut self) -> Result<Id, Error> {
|
||||
if self.text() != "id" {
|
||||
return Ok(Id::new_v4());
|
||||
}
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(),
|
||||
});
|
||||
}
|
||||
Id::parse_str(&value).map_err(|_| Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: "Invalid UUID identity.".into(),
|
||||
})
|
||||
}
|
||||
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
|
||||
self.take("{")?;
|
||||
let mut fields = BTreeMap::new();
|
||||
while self.text() != "}" {
|
||||
let key = self.text().to_owned();
|
||||
let offset = self.current().span.start;
|
||||
if !matches!(
|
||||
self.current().class,
|
||||
TokenClass::Keyword | TokenClass::Identifier
|
||||
) {
|
||||
return Err(self.error("Expected a field name or '}'."));
|
||||
}
|
||||
self.cursor += 1;
|
||||
let value = self.value()?;
|
||||
self.take(";")?;
|
||||
if fields.insert(key.clone(), value).is_some() {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: std::format!("Duplicate field '{key}'."),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.take("}")?;
|
||||
Ok(fields)
|
||||
}
|
||||
fn value(&mut self) -> Result<Ast, Error> {
|
||||
if self.value_depth >= 64 {
|
||||
return Err(self.error("Typed values may not nest more than 64 levels."));
|
||||
}
|
||||
self.value_depth += 1;
|
||||
let result = self.value_inner();
|
||||
self.value_depth -= 1;
|
||||
result
|
||||
}
|
||||
fn value_inner(&mut self) -> Result<Ast, Error> {
|
||||
let token = self.current();
|
||||
let offset = token.span.start;
|
||||
match self.text() {
|
||||
"@" => {
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Reference(self.identifier()?, offset))
|
||||
}
|
||||
"{" => Ok(Ast::Object(self.fields()?, offset)),
|
||||
"[" => {
|
||||
self.cursor += 1;
|
||||
let mut values = Vec::new();
|
||||
if self.text() != "]" {
|
||||
loop {
|
||||
values.push(self.value()?);
|
||||
if self.text() != "," {
|
||||
break;
|
||||
}
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
self.take("]")?;
|
||||
Ok(Ast::Array(values, offset))
|
||||
}
|
||||
"none" => {
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Literal(Value::Null, offset))
|
||||
}
|
||||
"true" | "false" => {
|
||||
let value = self.text() == "true";
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Literal(Value::Bool(value), offset))
|
||||
}
|
||||
_ => match token.class {
|
||||
TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)),
|
||||
TokenClass::Number => {
|
||||
let value = serde_json::from_str(self.text())
|
||||
.map_err(|_| self.error("Expected a finite JSON decimal number."))?;
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Literal(value, offset))
|
||||
}
|
||||
TokenClass::Identifier | TokenClass::Keyword => {
|
||||
let value = self.text().to_owned();
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Word(value, offset))
|
||||
}
|
||||
_ => Err(self.error("Expected a value, @alias reference, list, or typed record.")),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn document(&mut self) -> Result<super::ParsedDocument, Error> {
|
||||
self.take("arcadia")?;
|
||||
if self.text() != "2" {
|
||||
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
|
||||
}
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
self.take("project")?;
|
||||
let alias = self.identifier()?;
|
||||
let name = self.quoted()?;
|
||||
let id = self.optional_id()?;
|
||||
self.take("{")?;
|
||||
let mut project = Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id,
|
||||
alias: alias.clone(),
|
||||
name,
|
||||
description: String::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
};
|
||||
let mut aliases = BTreeMap::from([(alias, id)]);
|
||||
let mut locations = BTreeMap::from([(id, 0)]);
|
||||
let mut declarations = Vec::new();
|
||||
let mut graph_count = 0usize;
|
||||
let mut field_locations = BTreeMap::new();
|
||||
let mut has_description = false;
|
||||
while self.text() != "}" {
|
||||
if self.text() == "description" {
|
||||
if has_description {
|
||||
return Err(self.error("Duplicate project description."));
|
||||
}
|
||||
has_description = true;
|
||||
self.cursor += 1;
|
||||
project.description = self.quoted()?;
|
||||
self.take(";")?;
|
||||
continue;
|
||||
}
|
||||
let offset = self.current().span.start;
|
||||
let word = self.text().to_owned();
|
||||
let kind = declaration_kind(&word)
|
||||
.ok_or_else(|| self.error(std::format!("Unknown declaration '{word}'.")))?;
|
||||
graph_count += usize::from(matches!(
|
||||
kind,
|
||||
DeclKind::Element(..) | DeclKind::Relation(..)
|
||||
));
|
||||
self.limits
|
||||
.check_counts(graph_count, declarations.len().saturating_add(2), 0)
|
||||
.map_err(|message| Error {
|
||||
code: "model_limit",
|
||||
offset: self.current().span.start,
|
||||
message,
|
||||
})?;
|
||||
self.cursor += 1;
|
||||
let alias = self.identifier()?;
|
||||
let name = if matches!(kind, DeclKind::Node) {
|
||||
String::new()
|
||||
} else {
|
||||
self.quoted()?
|
||||
};
|
||||
let id = self.optional_id()?;
|
||||
let fields = self.fields()?;
|
||||
for (field, value) in &fields {
|
||||
field_locations.insert((id, field.clone()), value.offset());
|
||||
}
|
||||
if aliases.insert(alias.clone(), id).is_some() {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: std::format!("Duplicate alias '{alias}'."),
|
||||
});
|
||||
}
|
||||
if locations.insert(id, offset).is_some() {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: std::format!("Duplicate identity '{id}'."),
|
||||
});
|
||||
}
|
||||
declarations.push(Declaration {
|
||||
kind,
|
||||
alias,
|
||||
name,
|
||||
id,
|
||||
fields,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
self.take("}")?;
|
||||
if !self.text().is_empty() {
|
||||
return Err(self.error("Unexpected content after the project closing brace."));
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
for declaration in declarations {
|
||||
let mut fields = declaration
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(k, v)| Ok((k.clone(), v.resolve(k, &aliases)?)))
|
||||
.collect::<Result<Map<_, _>, Error>>()?;
|
||||
let d = &declaration;
|
||||
let conversion_error = |message: String| Error {
|
||||
code: "invalid_source",
|
||||
offset: d
|
||||
.fields
|
||||
.iter()
|
||||
.find(|(field, _)| message.contains(&std::format!("'{field}'")))
|
||||
.map_or(d.offset, |(_, value)| value.offset()),
|
||||
message,
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let parent =
|
||||
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
||||
let mut initial = ElementBody::new(kind, layer).unwrap();
|
||||
// Schema-2 sources predate explicit scenario viewpoints.
|
||||
// Preserve their mixed lifelines instead of reinterpreting
|
||||
// old source using a new graphical factory's typed default.
|
||||
if !fields.contains_key("viewpoint")
|
||||
&& let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut()
|
||||
{
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let body_fields =
|
||||
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, body_fields);
|
||||
let body: ElementBody = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
return Err(conversion_error(
|
||||
"Declaration kind disagrees with its typed body.".into(),
|
||||
));
|
||||
}
|
||||
project.elements.insert(
|
||||
d.id,
|
||||
Element {
|
||||
id: d.id,
|
||||
alias: d.alias.clone(),
|
||||
name: d.name.clone(),
|
||||
description,
|
||||
parent,
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
for required in ["source", "target"] {
|
||||
if !fields.contains_key(required) {
|
||||
return Err(conversion_error(std::format!(
|
||||
"Missing required field '{required}'."
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut body =
|
||||
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
||||
.unwrap();
|
||||
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, merged);
|
||||
let body = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
project.relations.insert(
|
||||
d.id,
|
||||
Relation {
|
||||
id: d.id,
|
||||
alias: d.alias.clone(),
|
||||
name: d.name.clone(),
|
||||
description,
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview"});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
record.insert("nodes".into(), serde_json::json!({}));
|
||||
let diagram: Diagram = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
project.diagrams.insert(d.id, diagram);
|
||||
}
|
||||
DeclKind::Node => {
|
||||
let diagram =
|
||||
take_required_id(&mut fields, "diagram").map_err(&conversion_error)?;
|
||||
if !fields.contains_key("element") {
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
nodes.push((diagram, node, d.offset));
|
||||
}
|
||||
DeclKind::Artifact => {
|
||||
if !fields.contains_key("owner") {
|
||||
return Err(conversion_error("Missing required field 'owner'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let artifact = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
project.artifact_links.insert(d.id, artifact);
|
||||
}
|
||||
DeclKind::Trace => {
|
||||
for required in ["oa", "sa"] {
|
||||
if !fields.contains_key(required) {
|
||||
return Err(conversion_error(std::format!(
|
||||
"Missing required field '{required}'."
|
||||
)));
|
||||
}
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
project.traces.insert(d.id, trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (diagram, node, offset) in nodes {
|
||||
let target = project.diagrams.get_mut(&diagram).ok_or_else(|| Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: "Node 'diagram' must reference a diagram declaration.".into(),
|
||||
})?;
|
||||
target.nodes.insert(node.id, node);
|
||||
}
|
||||
crate::persistence::v2::check_limits(&project, self.limits).map_err(|message| Error {
|
||||
code: "model_limit",
|
||||
offset: 0,
|
||||
message,
|
||||
})?;
|
||||
let decoded =
|
||||
crate::persistence::migrate_v2_limited(project, self.limits).map_err(|error| {
|
||||
let offset = match &error {
|
||||
crate::persistence::FileError::Model { issues, .. } => {
|
||||
self.semantic_error = issues.first().cloned();
|
||||
issues
|
||||
.first()
|
||||
.and_then(|issue| {
|
||||
issue.element.and_then(|id| {
|
||||
issue
|
||||
.field
|
||||
.as_ref()
|
||||
.and_then(|field| field_locations.get(&(id, field.clone())))
|
||||
.or_else(|| locations.get(&id))
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"invalid_source"
|
||||
},
|
||||
offset,
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(super::ParsedDocument {
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
locations,
|
||||
fields: field_locations,
|
||||
})
|
||||
}
|
||||
}
|
||||
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
|
||||
fields.insert("id".into(), Value::String(d.id.to_string()));
|
||||
fields.insert("alias".into(), Value::String(d.alias.clone()));
|
||||
if named {
|
||||
fields.insert("name".into(), Value::String(d.name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse(
|
||||
source: &str,
|
||||
tokens: Vec<Lexeme>,
|
||||
limits: ParseLimits,
|
||||
) -> Result<super::ParsedDocument, Vec<super::Diagnostic>> {
|
||||
let mut parser = Parser2 {
|
||||
source,
|
||||
tokens,
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits,
|
||||
semantic_error: None,
|
||||
};
|
||||
parser.document().map_err(|error| {
|
||||
let mut diagnostic = super::diagnostic(source, error);
|
||||
if let Some(issue) = parser.semantic_error {
|
||||
diagnostic.code = issue.code;
|
||||
diagnostic.object_id = issue.element;
|
||||
diagnostic.field = issue.field;
|
||||
}
|
||||
vec![diagnostic]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
|
||||
//! Structural state-machine rules. Expressions describe intent; they are not executed.
|
||||
use super::{
|
||||
model::*,
|
||||
validation::{Issue, ancestor_of_kind, error, warning},
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn ancestors(p: &Project, id: Id) -> Vec<Id> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut seen = BTreeSet::from([id]);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
result.push(id);
|
||||
current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
fn within(p: &Project, id: Id, owner: Id) -> bool {
|
||||
ancestors(p, id).contains(&owner)
|
||||
}
|
||||
fn boundary(p: &Project, vertex: &Element) -> Option<Id> {
|
||||
vertex
|
||||
.parent
|
||||
.and_then(|region| p.elements.get(®ion))
|
||||
.and_then(|region| region.parent)
|
||||
}
|
||||
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
|
||||
ancestors(p, vertex).into_iter().find(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner))
|
||||
})
|
||||
}
|
||||
fn edges<'a>(
|
||||
p: &'a Project,
|
||||
candidate: Option<&'a Relation>,
|
||||
) -> impl Iterator<Item = &'a Relation> {
|
||||
p.relations
|
||||
.values()
|
||||
.filter(move |r| candidate.is_none_or(|c| c.id != r.id))
|
||||
.chain(candidate)
|
||||
.filter(|r| r.kind() == RelationKind::StateTransition)
|
||||
}
|
||||
fn guard(r: &Relation) -> &str {
|
||||
match &r.body {
|
||||
RelationBody::StateTransition { guard, .. } => guard.trim(),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
fn orthogonal(p: &Project, arms: &[Id]) -> bool {
|
||||
let Some(first) = arms.first() else {
|
||||
return true;
|
||||
};
|
||||
if arms.len() < 2 {
|
||||
return true;
|
||||
}
|
||||
let Some(owner) = ancestors(p, *first).into_iter().find(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine)
|
||||
&& arms.iter().all(|arm| within(p, *arm, *id))
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let regions: Option<BTreeSet<_>> = arms
|
||||
.iter()
|
||||
.map(|arm| first_region(p, *arm, owner))
|
||||
.collect();
|
||||
regions.is_some_and(|regions| regions.len() == arms.len())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_transition(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
a: &Element,
|
||||
b: &Element,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
trigger,
|
||||
triggers,
|
||||
guard,
|
||||
..
|
||||
} = &r.body
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let machine = ancestor_of_kind(p, a.id, Kind::StateMachine);
|
||||
if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"transition_machine",
|
||||
"endpoints",
|
||||
"State transition endpoints belong to one state machine.",
|
||||
);
|
||||
}
|
||||
if !a.kind().is_state() || !b.kind().is_state() {
|
||||
return;
|
||||
}
|
||||
if a.id == b.id && !a.kind().is_stable_state() {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"pseudostate_self",
|
||||
"endpoints",
|
||||
"Only a State or Mode can have a self-transition.",
|
||||
);
|
||||
}
|
||||
let valid_kind = match transition_kind {
|
||||
TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id,
|
||||
TransitionKind::Local if a.kind() == Kind::EntryPoint => {
|
||||
boundary(p, a).is_some_and(|owner| within(p, b.id, owner))
|
||||
}
|
||||
TransitionKind::Local => {
|
||||
a.kind().is_stable_state()
|
||||
&& p.children(a.id).any(|e| e.kind() == Kind::Region)
|
||||
&& within(p, b.id, a.id)
|
||||
}
|
||||
TransitionKind::External => a.kind() != Kind::EntryPoint,
|
||||
};
|
||||
if !valid_kind {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"transition_kind",
|
||||
"transition_kind",
|
||||
"Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.",
|
||||
);
|
||||
}
|
||||
if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"pseudostate_direction",
|
||||
"endpoints",
|
||||
"Final/Terminate have no outgoing transitions; Initial has no incoming transition.",
|
||||
);
|
||||
}
|
||||
let triggered = !trigger.trim().is_empty() || !triggers.is_empty();
|
||||
if !a.kind().is_stable_state() && triggered {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
if a.kind() == Kind::Initial {
|
||||
"initial_transition"
|
||||
} else {
|
||||
"pseudostate_trigger"
|
||||
},
|
||||
"triggers",
|
||||
"Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.",
|
||||
);
|
||||
}
|
||||
if (matches!(
|
||||
a.kind(),
|
||||
Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory
|
||||
) || b.kind() == Kind::Join)
|
||||
&& (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered))
|
||||
{
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
if a.kind() == Kind::Initial {
|
||||
"initial_transition"
|
||||
} else {
|
||||
"pseudostate_segment"
|
||||
},
|
||||
"guard",
|
||||
"Initial/history fallback and Fork output/Join input segments have no guard or trigger.",
|
||||
);
|
||||
}
|
||||
if a.kind() == Kind::Fork && !b.kind().is_stable_state()
|
||||
|| b.kind() == Kind::Join && !a.kind().is_stable_state()
|
||||
{
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"orthogonal_endpoint",
|
||||
"endpoints",
|
||||
"Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.",
|
||||
);
|
||||
}
|
||||
if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) {
|
||||
let scope = a.parent.is_some_and(|region| {
|
||||
if a.kind() == Kind::ShallowHistory {
|
||||
b.parent == Some(region)
|
||||
} else {
|
||||
within(p, b.id, region)
|
||||
}
|
||||
});
|
||||
if !b.kind().is_stable_state() || !scope {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"history_scope",
|
||||
"target",
|
||||
"Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (point, other, outgoing) in [(a, b, true), (b, a, false)] {
|
||||
if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) {
|
||||
continue;
|
||||
}
|
||||
let Some(owner) = boundary(p, point) else {
|
||||
continue;
|
||||
};
|
||||
let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing;
|
||||
if within(p, other.id, owner) != needs_inside || other.id == owner {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"boundary_transition",
|
||||
"endpoints",
|
||||
"EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_vertex(
|
||||
p: &Project,
|
||||
e: &Element,
|
||||
candidate: Option<&Relation>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if !e.kind().is_state() {
|
||||
return;
|
||||
}
|
||||
let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect();
|
||||
let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect();
|
||||
let (ins, outs) = (incoming.len(), outgoing.len());
|
||||
let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1);
|
||||
let max_out = matches!(
|
||||
e.kind(),
|
||||
Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint
|
||||
)
|
||||
.then_some(1);
|
||||
if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
if e.kind() == Kind::Initial {
|
||||
"initial_transition_count"
|
||||
} else {
|
||||
"pseudostate_cardinality"
|
||||
},
|
||||
"body",
|
||||
format!(
|
||||
"{} has too many {} transitions.",
|
||||
e.kind(),
|
||||
if max_in.is_some_and(|max| ins > max) {
|
||||
"incoming"
|
||||
} else {
|
||||
"outgoing"
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
let incomplete = match e.kind() {
|
||||
Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0,
|
||||
Kind::Final | Kind::Terminate => ins == 0,
|
||||
Kind::Choice | Kind::Fork => ins == 0 || outs < 2,
|
||||
Kind::Join => ins < 2 || outs == 0,
|
||||
Kind::Junction => ins == 0 || outs == 0,
|
||||
Kind::EntryPoint => outs == 0,
|
||||
Kind::ExitPoint => {
|
||||
ins == 0
|
||||
|| (outs == 0
|
||||
&& boundary(p, e)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| e.kind() != Kind::StateMachine))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if incomplete {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"incomplete_state_vertex",
|
||||
"body",
|
||||
format!(
|
||||
"Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.",
|
||||
e.kind()
|
||||
),
|
||||
);
|
||||
}
|
||||
if e.kind() == Kind::Choice {
|
||||
let defaults = outgoing.iter().filter(|r| guard(r) == "else").count();
|
||||
if defaults > 1 {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"choice_default",
|
||||
"guard",
|
||||
"Choice has at most one literal else branch.",
|
||||
);
|
||||
}
|
||||
if defaults == 0 {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"missing_choice_default",
|
||||
"guard",
|
||||
"Add an else branch, or review and document why no alternative may apply.",
|
||||
);
|
||||
}
|
||||
if outgoing.iter().any(|r| guard(r).is_empty()) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"missing_choice_guard",
|
||||
"guard",
|
||||
"Each Choice alternative needs an explicit guard or literal else.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"junction_guard",
|
||||
"guard",
|
||||
"Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.",
|
||||
);
|
||||
}
|
||||
if matches!(e.kind(), Kind::Fork | Kind::Join) {
|
||||
let arms: Vec<_> = if e.kind() == Kind::Fork {
|
||||
outgoing.iter().map(|r| r.target()).collect()
|
||||
} else {
|
||||
incoming.iter().map(|r| r.source()).collect()
|
||||
};
|
||||
if !orthogonal(p, &arms) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"orthogonal_regions",
|
||||
"endpoints",
|
||||
"Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if e.kind() == Kind::EntryPoint
|
||||
&& let Some(owner) = boundary(p, e)
|
||||
{
|
||||
let regions: Vec<_> = outgoing
|
||||
.iter()
|
||||
.filter_map(|r| first_region(p, r.target(), owner))
|
||||
.collect();
|
||||
let distinct: BTreeSet<_> = regions.iter().copied().collect();
|
||||
if distinct.len() != regions.len() {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"entry_point_cardinality",
|
||||
"target",
|
||||
"EntryPoint has at most one outgoing transition per destination region.",
|
||||
);
|
||||
}
|
||||
if p.children(owner)
|
||||
.filter(|r| r.kind() == Kind::Region)
|
||||
.any(|r| {
|
||||
!distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial)
|
||||
})
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"entry_region_default",
|
||||
"target",
|
||||
"Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_graph(p: &Project, out: &mut Vec<Issue>) {
|
||||
for e in p.elements.values() {
|
||||
validate_vertex(p, e, None, out);
|
||||
if e.kind() == Kind::Region {
|
||||
for (kind, code) in [
|
||||
(Kind::Initial, "multiple_initial"),
|
||||
(Kind::ShallowHistory, "multiple_shallow_history"),
|
||||
(Kind::DeepHistory, "multiple_deep_history"),
|
||||
] {
|
||||
if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
code,
|
||||
"parent",
|
||||
format!("A region has at most one {kind}."),
|
||||
);
|
||||
}
|
||||
}
|
||||
let explicit_entry =
|
||||
edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id));
|
||||
if !p
|
||||
.children(e.id)
|
||||
.any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint))
|
||||
&& !explicit_entry
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"missing_initial",
|
||||
"parent",
|
||||
"Add an Initial default entry or an explicit incoming entry path for this region.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if e.kind() == Kind::StateMachine {
|
||||
validate_reachability(p, e.id, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_reachability(p: &Project, machine: Id, out: &mut Vec<Issue>) {
|
||||
let mut pending: Vec<_> = p
|
||||
.children(machine)
|
||||
.filter(|e| e.kind() == Kind::Region)
|
||||
.flat_map(|region| p.children(region.id))
|
||||
.filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint))
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let mut reached = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !reached.insert(id) {
|
||||
continue;
|
||||
}
|
||||
pending.extend(
|
||||
edges(p, None)
|
||||
.filter(|r| r.source() == id)
|
||||
.map(Relation::target),
|
||||
);
|
||||
// Entering a nested vertex activates its composite ancestors. Reaching a
|
||||
// composite also permits each region's default entry; this deliberately
|
||||
// over-approximates guards/history instead of pretending to execute them.
|
||||
pending.extend(ancestors(p, id).into_iter().filter(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind().is_stable_state())
|
||||
}));
|
||||
pending.extend(
|
||||
p.children(id)
|
||||
.filter(|e| e.kind() == Kind::Region)
|
||||
.flat_map(|region| p.children(region.id))
|
||||
.filter(|e| e.kind() == Kind::Initial)
|
||||
.map(|e| e.id),
|
||||
);
|
||||
}
|
||||
for e in p.elements.values().filter(|e| {
|
||||
e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
|
||||
}) {
|
||||
if !reached.contains(&e.id) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"unreachable_state",
|
||||
"body",
|
||||
"No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::model::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Drift {
|
||||
Unreviewed,
|
||||
Unchanged,
|
||||
OaChanged,
|
||||
SaChanged,
|
||||
BothChanged,
|
||||
}
|
||||
pub fn trace_drift(p: &Project, t: &Trace) -> Drift {
|
||||
let Some(base) = &t.baseline else {
|
||||
return Drift::Unreviewed;
|
||||
};
|
||||
let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else {
|
||||
return Drift::Unreviewed;
|
||||
};
|
||||
match (oa != base.oa, sa != base.sa) {
|
||||
(false, false) => Drift::Unchanged,
|
||||
(true, false) => Drift::OaChanged,
|
||||
(false, true) => Drift::SaChanged,
|
||||
(true, true) => Drift::BothChanged,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+105
-12
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 2;
|
||||
pub const SCHEMA_VERSION: u32 = 3;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -293,6 +293,54 @@ pub enum PortDirection {
|
||||
InOut,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ComponentPortKind {
|
||||
Standard,
|
||||
Flow,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ComponentPortOrientation {
|
||||
Unset,
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LegacyPortContract {
|
||||
pub direction: PortDirection,
|
||||
pub interface: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum PortContract {
|
||||
FunctionInput {
|
||||
incoming_items: Vec<Id>,
|
||||
},
|
||||
FunctionOutput {
|
||||
outgoing_items: Vec<Id>,
|
||||
},
|
||||
Component {
|
||||
kind: ComponentPortKind,
|
||||
orientation: ComponentPortOrientation,
|
||||
},
|
||||
Legacy,
|
||||
}
|
||||
impl PortContract {
|
||||
pub fn items(&self) -> &[Id] {
|
||||
match self {
|
||||
Self::FunctionInput { incoming_items } => incoming_items,
|
||||
Self::FunctionOutput { outgoing_items } => outgoing_items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
pub fn is_function(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::FunctionInput { .. } | Self::FunctionOutput { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StateKind {
|
||||
State,
|
||||
Mode,
|
||||
@@ -468,8 +516,8 @@ pub enum SharedBody {
|
||||
items: Vec<Id>,
|
||||
},
|
||||
Port {
|
||||
direction: PortDirection,
|
||||
interface: Option<Id>,
|
||||
contract: PortContract,
|
||||
legacy: Option<LegacyPortContract>,
|
||||
},
|
||||
Scenario {
|
||||
#[serde(default)]
|
||||
@@ -635,10 +683,8 @@ impl ElementBody {
|
||||
unit: String::new(),
|
||||
},
|
||||
Kind::Interface => SharedBody::Interface { items: vec![] },
|
||||
Kind::Port => SharedBody::Port {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
},
|
||||
// Port creation requires an explicit contract and owner.
|
||||
Kind::Port => return None,
|
||||
Kind::Scenario => SharedBody::Scenario {
|
||||
viewpoint: if layer == Layer::Oa {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
@@ -707,6 +753,7 @@ impl SharedBody {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
Allocation,
|
||||
PortAllocation,
|
||||
Interaction,
|
||||
CapabilityInvolvement,
|
||||
ChainInvolvement,
|
||||
@@ -728,8 +775,9 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 20] = [
|
||||
pub const ALL: [Self; 21] = [
|
||||
Self::Allocation,
|
||||
Self::PortAllocation,
|
||||
Self::Interaction,
|
||||
Self::CapabilityInvolvement,
|
||||
Self::ChainInvolvement,
|
||||
@@ -753,6 +801,7 @@ impl RelationKind {
|
||||
pub fn permits(self, from: Kind, to: Kind) -> bool {
|
||||
match self {
|
||||
Self::Allocation => from.is_performer() && to.is_work(),
|
||||
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
|
||||
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
|
||||
Self::CapabilityInvolvement => {
|
||||
(from.is_performer() || from.is_work() || from == Kind::Role)
|
||||
@@ -807,6 +856,7 @@ impl RelationKind {
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Allocation => "Performer → leaf activity/function",
|
||||
Self::PortAllocation => "Component port → allocated function port",
|
||||
Self::Interaction => "OA activity → OA activity",
|
||||
Self::CapabilityInvolvement => "Participant/activity/function → capability",
|
||||
Self::ChainInvolvement => "Process/chain → capability",
|
||||
@@ -837,6 +887,7 @@ impl fmt::Display for RelationKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Allocation => "Allocation",
|
||||
Self::PortAllocation => "Port allocation",
|
||||
Self::Interaction => "Interaction",
|
||||
Self::CapabilityInvolvement => "Capability involvement",
|
||||
Self::ChainInvolvement => "Chain involvement",
|
||||
@@ -875,6 +926,10 @@ pub enum RelationBody {
|
||||
source: Id,
|
||||
target: Id,
|
||||
},
|
||||
PortAllocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
},
|
||||
Interaction {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -899,13 +954,14 @@ pub enum RelationBody {
|
||||
FunctionalExchange {
|
||||
source: Id,
|
||||
target: Id,
|
||||
source_port: Option<Id>,
|
||||
target_port: Option<Id>,
|
||||
source_port: Id,
|
||||
target_port: Id,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
BoundaryExchange {
|
||||
source: Id,
|
||||
target: Id,
|
||||
oriented: bool,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
MissionCapability {
|
||||
@@ -984,6 +1040,7 @@ impl RelationBody {
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
match self {
|
||||
Self::Allocation { .. } => RelationKind::Allocation,
|
||||
Self::PortAllocation { .. } => RelationKind::PortAllocation,
|
||||
Self::Interaction { .. } => RelationKind::Interaction,
|
||||
Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement,
|
||||
Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement,
|
||||
@@ -1008,6 +1065,7 @@ impl RelationBody {
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
| Self::ChainInvolvement { source, target }
|
||||
@@ -1032,6 +1090,7 @@ impl RelationBody {
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
| Self::ChainInvolvement { source, target }
|
||||
@@ -1067,6 +1126,7 @@ impl RelationBody {
|
||||
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
|
||||
match kind {
|
||||
RelationKind::Allocation => Self::Allocation { source, target },
|
||||
RelationKind::PortAllocation => Self::PortAllocation { source, target },
|
||||
RelationKind::Interaction => Self::Interaction {
|
||||
source,
|
||||
target,
|
||||
@@ -1079,13 +1139,15 @@ impl RelationBody {
|
||||
RelationKind::FunctionalExchange => Self::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port: None,
|
||||
target_port: None,
|
||||
// Unbound drafts must be completed before candidate admission.
|
||||
source_port: Id::nil(),
|
||||
target_port: Id::nil(),
|
||||
items: vec![],
|
||||
},
|
||||
RelationKind::BoundaryExchange => Self::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
},
|
||||
RelationKind::MissionCapability => Self::MissionCapability { source, target },
|
||||
@@ -1584,6 +1646,37 @@ impl Project {
|
||||
pub fn relate(&mut self, kind: RelationKind, name: &str, source: Id, target: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let alias = self.fresh_alias(name, "relation", id);
|
||||
if kind == RelationKind::FunctionalExchange
|
||||
&& self.elements.contains_key(&source)
|
||||
&& self.elements.contains_key(&target)
|
||||
{
|
||||
let request = crate::ports::FunctionalExchangeRequest {
|
||||
id,
|
||||
alias,
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
source,
|
||||
target,
|
||||
source_port: crate::ports::PortChoice::Auto,
|
||||
target_port: crate::ports::PortChoice::Auto,
|
||||
items: vec![],
|
||||
};
|
||||
// Project is the unchecked builder; Session validates the complete batch.
|
||||
for command in crate::ports::functional_exchange_commands(self, &request)
|
||||
.expect("fresh exchange identities")
|
||||
{
|
||||
match command {
|
||||
crate::Command::CreateElement(e) => {
|
||||
self.elements.insert(e.id, e);
|
||||
}
|
||||
crate::Command::CreateRelation(r) => {
|
||||
self.relations.insert(r.id, r);
|
||||
}
|
||||
_ => unreachable!("functional creation only creates ports and an exchange"),
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
let mut body = RelationBody::new(kind, source, target);
|
||||
if self
|
||||
.elements
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MigrationReport {
|
||||
pub from_schema: u32,
|
||||
pub to_schema: u32,
|
||||
pub preserved_ports: Vec<PreservedPort>,
|
||||
pub generated_endpoints: Vec<GeneratedEndpoint>,
|
||||
pub orientation_defaults: Vec<OrientationDefault>,
|
||||
pub required_resolutions: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PreservedPort {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub required_resolution: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GeneratedEndpoint {
|
||||
pub exchange: Id,
|
||||
pub side: String,
|
||||
pub port: Id,
|
||||
pub owner: Id,
|
||||
pub alias: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OrientationDefault {
|
||||
pub exchange: Id,
|
||||
pub oriented: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodeResult {
|
||||
pub project: Project,
|
||||
pub migration: Option<MigrationReport>,
|
||||
}
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FileError {
|
||||
#[error("File operation failed: {0}")]
|
||||
@@ -11,6 +53,11 @@ pub enum FileError {
|
||||
Text(String),
|
||||
#[error("Invalid project: {0}")]
|
||||
Invalid(String),
|
||||
#[error("Invalid project: {message}")]
|
||||
Model {
|
||||
message: String,
|
||||
issues: Vec<validation::Issue>,
|
||||
},
|
||||
#[error(
|
||||
"This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes."
|
||||
)]
|
||||
@@ -19,21 +66,30 @@ pub enum FileError {
|
||||
Limit(String),
|
||||
}
|
||||
pub fn decode(bytes: &[u8]) -> Result<Project, FileError> {
|
||||
decode_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
||||
decode_with_report(bytes).map(|decoded| decoded.project)
|
||||
}
|
||||
pub fn decode_with_report(bytes: &[u8]) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
||||
}
|
||||
|
||||
/// Apply admission budgets before validating a candidate or its migrated form.
|
||||
pub fn decode_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<Project, FileError> {
|
||||
decode_with_report_limited(bytes, limits).map(|decoded| decoded.project)
|
||||
}
|
||||
/// Admission budgets cover the original graph and all generated migration data.
|
||||
/// Decoding never rewrites a file or advances a hosted revision.
|
||||
pub fn decode_with_report_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if bytes.len() > limits.max_source_bytes {
|
||||
return Err(FileError::Limit(format!(
|
||||
"Input exceeds {} bytes.",
|
||||
limits.max_source_bytes
|
||||
)));
|
||||
}
|
||||
let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
match header
|
||||
.get("schema_version")
|
||||
@@ -42,43 +98,63 @@ pub fn decode_limited(
|
||||
Some(1) => {
|
||||
let old = serde_json::from_slice(bytes)?;
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
migrate_v1_limited(old, limits).map_err(FileError::Invalid)?
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => serde_json::from_slice(bytes)?,
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1 or 2.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
|
||||
crate::language::parse_limited(text, limits).map_err(|diagnostics| {
|
||||
crate::language::parse_with_report_limited(text, limits).map_err(|diagnostics| {
|
||||
let detail = diagnostics
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if diagnostics.iter().any(|diagnostic| {
|
||||
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
|
||||
}) {
|
||||
if diagnostics
|
||||
.iter()
|
||||
.any(|d| matches!(d.code.as_str(), "model_limit" | "source_limit"))
|
||||
{
|
||||
FileError::Limit(detail)
|
||||
} else {
|
||||
FileError::Text(detail)
|
||||
}
|
||||
})?
|
||||
};
|
||||
limits.check_model(&p).map_err(FileError::Limit)?;
|
||||
validate_project(&p)?;
|
||||
Ok(p)
|
||||
limits
|
||||
.check_model(&decoded.project)
|
||||
.map_err(FileError::Limit)?;
|
||||
validate_project(&decoded.project)?;
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
let normalized = normalize_v1_to_v2(old, limits).map_err(FileError::Invalid)?;
|
||||
let mut decoded = migrate_v2_limited(normalized, limits)?;
|
||||
if let Some(report) = &mut decoded.migration {
|
||||
report.from_schema = 1;
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
|
||||
/// coordinates survive; every newly required identity is deterministic.
|
||||
pub(crate) fn migrate_v1_limited(
|
||||
fn normalize_v1_to_v2(
|
||||
mut old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<Project, String> {
|
||||
) -> Result<v2::model::Project, String> {
|
||||
use v2::model::*;
|
||||
check_v1_limits(&old, limits)?;
|
||||
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
|
||||
return Err(issue.message);
|
||||
@@ -180,7 +256,10 @@ pub(crate) fn migrate_v1_limited(
|
||||
id,
|
||||
alias,
|
||||
element: e.id,
|
||||
position: e.position,
|
||||
position: Position {
|
||||
x: e.position.x,
|
||||
y: e.position.y,
|
||||
},
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
@@ -236,9 +315,9 @@ pub(crate) fn migrate_v1_limited(
|
||||
}
|
||||
*p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps;
|
||||
}
|
||||
if let Some(issue) = validation::validate(&p)
|
||||
if let Some(issue) = v2::validation::validate(&p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
.find(|i| i.severity == v2::validation::Severity::Error)
|
||||
{
|
||||
return Err(std::format!(
|
||||
"V1 migration cannot satisfy V2 validation: {}",
|
||||
@@ -263,7 +342,7 @@ pub(crate) fn check_v1_limits(
|
||||
let records = graph.saturating_add(old.elements.len()).saturating_add(2);
|
||||
limits.check_counts(graph, records, steps)
|
||||
}
|
||||
fn migration_id(p: &Project, role: &str) -> Id {
|
||||
fn migration_id(p: &v2::model::Project, role: &str) -> Id {
|
||||
let mut suffix = 0;
|
||||
loop {
|
||||
let id = Id::new_v5(
|
||||
@@ -478,12 +557,18 @@ fn validate_project(p: &Project) -> Result<(), FileError> {
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(FileError::Invalid(i.message));
|
||||
return Err(FileError::Model {
|
||||
message: i.message.clone(),
|
||||
issues: vec![i],
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
decode(&std::fs::read(path)?)
|
||||
load_with_report(path).map(|decoded| decoded.project)
|
||||
}
|
||||
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report(&std::fs::read(path)?)
|
||||
}
|
||||
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
||||
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ pub struct ProjectedEdge {
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub trace: bool,
|
||||
pub oriented: bool,
|
||||
pub source_port: Option<Id>,
|
||||
pub target_port: Option<Id>,
|
||||
}
|
||||
@@ -29,7 +30,36 @@ pub struct ProjectedPort {
|
||||
/// Placement identity of the owning visible function/participant.
|
||||
pub owner: Id,
|
||||
pub position: Position,
|
||||
pub direction: PortDirection,
|
||||
pub side: PortAnchorSide,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
|
||||
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
|
||||
return None;
|
||||
};
|
||||
Some(match contract {
|
||||
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
|
||||
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
} => PortAnchorSide::Left,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
} => PortAnchorSide::Right,
|
||||
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
|
||||
Some(PortDirection::In) => PortAnchorSide::Left,
|
||||
Some(PortDirection::Out) => PortAnchorSide::Right,
|
||||
_ => PortAnchorSide::Bottom,
|
||||
},
|
||||
_ => PortAnchorSide::Bottom,
|
||||
})
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ProjectedView {
|
||||
@@ -38,10 +68,25 @@ pub struct ProjectedView {
|
||||
pub ports: Vec<ProjectedPort>,
|
||||
}
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0);
|
||||
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
|
||||
.into_iter()
|
||||
.map(|side| {
|
||||
p.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(side))
|
||||
.count()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if count > 0 {
|
||||
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
||||
}
|
||||
let bottom = p
|
||||
.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
|
||||
.count();
|
||||
if bottom > 0 {
|
||||
size.width = size.width.max(28. * (bottom + 1) as f32);
|
||||
}
|
||||
size
|
||||
}
|
||||
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
|
||||
@@ -250,8 +295,15 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
children.sort_by_key(|e| (&e.alias, e.id));
|
||||
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
|
||||
let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect();
|
||||
for anchor_side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let side: Vec<_> = children
|
||||
.iter()
|
||||
.filter(|e| port_anchor_side(e) == Some(anchor_side))
|
||||
.collect();
|
||||
for (index, port) in side.iter().enumerate() {
|
||||
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
||||
let y = if p.elements[&node.element].kind().is_performer()
|
||||
@@ -263,16 +315,16 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
} else {
|
||||
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
|
||||
};
|
||||
let position = match direction {
|
||||
PortDirection::In => Position {
|
||||
let position = match anchor_side {
|
||||
PortAnchorSide::Left => Position {
|
||||
x: node.position.x,
|
||||
y,
|
||||
},
|
||||
PortDirection::Out => Position {
|
||||
PortAnchorSide::Right => Position {
|
||||
x: node.position.x + node.size.width,
|
||||
y,
|
||||
},
|
||||
PortDirection::InOut => Position {
|
||||
PortAnchorSide::Bottom => Position {
|
||||
x: node.position.x + node.size.width * fraction,
|
||||
y: node.position.y + node.size.height,
|
||||
},
|
||||
@@ -281,7 +333,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
element: port.id,
|
||||
owner: node.id,
|
||||
position,
|
||||
direction,
|
||||
side: anchor_side,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -324,10 +376,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => (*source_port, *target_port),
|
||||
RelationBody::BoundaryExchange { source, target, .. } => {
|
||||
(Some(*source), Some(*target))
|
||||
}
|
||||
} => (Some(*source_port), Some(*target_port)),
|
||||
RelationBody::BoundaryExchange { source, target, .. }
|
||||
| RelationBody::PortAllocation { source, target } => (Some(*source), Some(*target)),
|
||||
_ => (None, None),
|
||||
};
|
||||
edges.push(ProjectedEdge {
|
||||
@@ -335,6 +386,13 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
source,
|
||||
target,
|
||||
trace: false,
|
||||
oriented: !matches!(
|
||||
&r.body,
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
),
|
||||
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
});
|
||||
@@ -350,6 +408,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
source,
|
||||
target,
|
||||
trace: true,
|
||||
oriented: true,
|
||||
source_port: None,
|
||||
target_port: None,
|
||||
});
|
||||
|
||||
@@ -45,10 +45,20 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Port {
|
||||
interface: Some(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Port { contract, legacy } => {
|
||||
match contract {
|
||||
PortContract::FunctionInput { incoming_items } => {
|
||||
incoming_items.iter_mut().for_each(&mut visit)
|
||||
}
|
||||
PortContract::FunctionOutput { outgoing_items } => {
|
||||
outgoing_items.iter_mut().for_each(&mut visit)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(id) = legacy.as_mut().and_then(|old| old.interface.as_mut()) {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::Scenario { context, steps, .. } => {
|
||||
if let Some(id) = context {
|
||||
visit(id);
|
||||
@@ -145,7 +155,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
items,
|
||||
..
|
||||
} => {
|
||||
for id in [source_port, target_port].into_iter().flatten() {
|
||||
for id in [source_port, target_port] {
|
||||
visit(id);
|
||||
}
|
||||
for id in items {
|
||||
@@ -207,7 +217,49 @@ pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
}
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
if removed.contains(&r.source()) || removed.contains(&r.target()) {
|
||||
let port_removed = matches!(&r.body, RelationBody::FunctionalExchange { source_port, target_port, .. } if removed.contains(source_port) || removed.contains(target_port));
|
||||
let work_allocation_removed = r.kind() == RelationKind::PortAllocation
|
||||
&& p.relations.values().any(|allocation| {
|
||||
allocation.kind() == RelationKind::Allocation
|
||||
&& removed.contains(&allocation.id)
|
||||
&& p.elements.get(&r.source()).and_then(|e| e.parent)
|
||||
== Some(allocation.source())
|
||||
&& p.elements.get(&r.target()).and_then(|e| e.parent)
|
||||
== Some(allocation.target())
|
||||
});
|
||||
if removed.contains(&r.source())
|
||||
|| removed.contains(&r.target())
|
||||
|| port_removed
|
||||
|| work_allocation_removed
|
||||
{
|
||||
removed.insert(r.id);
|
||||
}
|
||||
}
|
||||
// Exchange allocation depends on both explicit endpoint allocations.
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
|
||||
{
|
||||
let (Some(boundary), Some(work)) =
|
||||
(p.relations.get(&r.source()), p.relations.get(&r.target()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = &work.body
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if p.relations.values().any(|allocation| {
|
||||
allocation.kind() == RelationKind::PortAllocation
|
||||
&& removed.contains(&allocation.id)
|
||||
&& [boundary.source(), boundary.target()].contains(&allocation.source())
|
||||
&& [*source_port, *target_port].contains(&allocation.target())
|
||||
}) {
|
||||
removed.insert(r.id);
|
||||
}
|
||||
}
|
||||
@@ -263,7 +315,20 @@ pub fn delete_cascade(p: &mut Project, id: Id) {
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Port { interface, .. } => clear(interface),
|
||||
SharedBody::Port { contract, legacy } => {
|
||||
match contract {
|
||||
PortContract::FunctionInput { incoming_items } => {
|
||||
incoming_items.retain(|id| !removed.contains(id))
|
||||
}
|
||||
PortContract::FunctionOutput { outgoing_items } => {
|
||||
outgoing_items.retain(|id| !removed.contains(id))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(old) = legacy {
|
||||
clear(&mut old.interface);
|
||||
}
|
||||
}
|
||||
SharedBody::Scenario { context, .. } => clear(context),
|
||||
SharedBody::Lifeline { represented } => clear(represented),
|
||||
SharedBody::Constraint { constrained, .. } => {
|
||||
@@ -336,15 +401,8 @@ pub fn delete_cascade(p: &mut Project, id: Id) {
|
||||
| RelationBody::BoundaryExchange { items, .. } => {
|
||||
items.retain(|id| !removed.contains(id))
|
||||
}
|
||||
RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => {
|
||||
RelationBody::FunctionalExchange { items, .. } => {
|
||||
items.retain(|id| !removed.contains(id));
|
||||
clear(source_port);
|
||||
clear(target_port);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -453,6 +453,24 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result<Option<Id
|
||||
Some(*id)
|
||||
}
|
||||
Command::Connect(kind, name, source, target) => {
|
||||
if *kind == RelationKind::FunctionalExchange {
|
||||
let id = Id::new_v4();
|
||||
let commands = crate::ports::functional_exchange_commands(
|
||||
p,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: p.fresh_alias(name, "relation", id),
|
||||
name: name.clone(),
|
||||
description: String::new(),
|
||||
source: *source,
|
||||
target: *target,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
items: vec![],
|
||||
},
|
||||
)?;
|
||||
return apply_command(p, &Command::Batch(commands), depth + 1);
|
||||
}
|
||||
Some(p.relate(*kind, name, *source, *target))
|
||||
}
|
||||
Command::Delete(id) => {
|
||||
|
||||
@@ -196,6 +196,11 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|
||||
})
|
||||
}
|
||||
fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
||||
if layer == Layer::Oa
|
||||
&& crate::ports::port_contract(e).is_some_and(|contract| *contract != PortContract::Legacy)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(shared) = e.shared()
|
||||
&& kind == e.kind()
|
||||
{
|
||||
@@ -232,8 +237,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port: None,
|
||||
target_port: None,
|
||||
source_port: Id::nil(),
|
||||
target_port: Id::nil(),
|
||||
items: items.clone(),
|
||||
}
|
||||
}
|
||||
@@ -320,6 +325,47 @@ fn value(props: &TracedProperties, field: TransitionField) -> &String {
|
||||
TransitionField::Description => &props.description,
|
||||
}
|
||||
}
|
||||
|
||||
/// SA port implementation details have no fabricated OA counterpart. Propagate
|
||||
/// omissions through dependent records, except the FE whose ports are removed
|
||||
/// by its explicit Interaction mapping.
|
||||
fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
|
||||
let mut omitted: BTreeSet<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == Layer::Sa
|
||||
&& crate::ports::port_contract(e).is_some_and(|c| *c != PortContract::Legacy)
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
loop {
|
||||
let before = omitted.len();
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
|
||||
{
|
||||
if crate::references::relation_references(r)
|
||||
.iter()
|
||||
.any(|id| omitted.contains(id))
|
||||
{
|
||||
omitted.insert(r.id);
|
||||
}
|
||||
}
|
||||
for e in p.elements.values().filter(|e| e.layer() == Layer::Sa) {
|
||||
if crate::references::element_references(e)
|
||||
.iter()
|
||||
.any(|id| omitted.contains(id))
|
||||
{
|
||||
omitted.insert(e.id);
|
||||
}
|
||||
}
|
||||
if before == omitted.len() {
|
||||
return omitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set(props: &mut TracedProperties, field: TransitionField, value: String) {
|
||||
match field {
|
||||
TransitionField::Name => props.name = value,
|
||||
@@ -341,6 +387,12 @@ pub fn plan_transition(
|
||||
commands: vec![],
|
||||
};
|
||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||
let omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
sa_port_omissions(project)
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
let mut reported_omissions = BTreeSet::new();
|
||||
if selected.len() != request.sources.len() {
|
||||
conflict(
|
||||
&mut plan,
|
||||
@@ -364,6 +416,9 @@ pub fn plan_transition(
|
||||
let before = selected.len();
|
||||
let existing: Vec<_> = selected.iter().copied().collect();
|
||||
for id in existing {
|
||||
if omitted.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
// Contextual links are owned by the process/chain shared by
|
||||
@@ -398,7 +453,27 @@ pub fn plan_transition(
|
||||
selected.extend(project.children(id).map(|e| e.id));
|
||||
}
|
||||
} else if let Some(r) = project.relations.get(&id) {
|
||||
selected.extend(crate::references::relation_references(r));
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& let RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
} = &r.body
|
||||
{
|
||||
// An Interaction needs the work/items, never either SA
|
||||
// endpoint port, including an unresolved legacy one.
|
||||
selected.extend([*source, *target]);
|
||||
selected.extend(items);
|
||||
for port in [*source_port, *target_port] {
|
||||
if reported_omissions.insert(port) {
|
||||
plan.warnings.push(format!("SA-only omission: functional endpoint {} ({port}) is not copied into the portless OA interaction; the original is preserved.", project.named(port)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selected.extend(crate::references::relation_references(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected.len() == before {
|
||||
@@ -406,6 +481,12 @@ pub fn plan_transition(
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.retain(|id| {
|
||||
if omitted.contains(id) {
|
||||
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
let mut choices = BTreeMap::new();
|
||||
for mapping in &request.mappings {
|
||||
if choices.insert(mapping.source, mapping).is_some() {
|
||||
@@ -569,7 +650,17 @@ pub fn plan_transition(
|
||||
}
|
||||
}
|
||||
let mut working = project.clone();
|
||||
for source in selected.iter().filter(|id| creating.contains(id)) {
|
||||
let ordered_creations: Vec<_> = selected
|
||||
.iter()
|
||||
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
|
||||
.chain(
|
||||
selected
|
||||
.iter()
|
||||
.filter(|id| creating.contains(id) && project.relations.contains_key(id)),
|
||||
)
|
||||
.copied()
|
||||
.collect();
|
||||
for source in &ordered_creations {
|
||||
let Some(target) = targets.get(source).copied() else {
|
||||
continue;
|
||||
};
|
||||
@@ -643,6 +734,12 @@ pub fn plan_transition(
|
||||
}
|
||||
});
|
||||
for dependency in crate::references::relation_references(&created) {
|
||||
if dependency.is_nil()
|
||||
&& request.direction == TransitionDirection::OaToSa
|
||||
&& r.kind() == RelationKind::Interaction
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !targets.values().any(|id| *id == dependency)
|
||||
&& working.layer_of(dependency) != Some(request.direction.target_layer())
|
||||
{
|
||||
@@ -660,8 +757,52 @@ pub fn plan_transition(
|
||||
if !request.fields.contains(&TransitionField::Description) {
|
||||
created.description.clear();
|
||||
}
|
||||
working.relations.insert(target, created.clone());
|
||||
plan.commands.push(Command::CreateRelation(created));
|
||||
if request.direction == TransitionDirection::OaToSa
|
||||
&& let RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target: destination,
|
||||
items,
|
||||
..
|
||||
} = &created.body
|
||||
{
|
||||
let request = FunctionalExchangeRequest {
|
||||
id: created.id,
|
||||
alias: created.alias.clone(),
|
||||
name: created.name.clone(),
|
||||
description: created.description.clone(),
|
||||
source: *source,
|
||||
target: *destination,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
items: items.clone(),
|
||||
};
|
||||
match crate::ports::functional_exchange_commands(&working, &request) {
|
||||
Ok(commands) => {
|
||||
for command in &commands {
|
||||
match command {
|
||||
Command::CreateElement(e) => {
|
||||
working.elements.insert(e.id, e.clone());
|
||||
}
|
||||
Command::CreateRelation(r) => {
|
||||
working.relations.insert(r.id, r.clone());
|
||||
}
|
||||
_ => unreachable!("functional creation only creates objects"),
|
||||
}
|
||||
}
|
||||
plan.commands.extend(commands);
|
||||
}
|
||||
Err(err) => conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
format!("Functional endpoint creation: {err}"),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
working.relations.insert(target, created.clone());
|
||||
plan.commands.push(Command::CreateRelation(created));
|
||||
}
|
||||
}
|
||||
}
|
||||
for source in &selected {
|
||||
|
||||
@@ -450,21 +450,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
});
|
||||
}
|
||||
}
|
||||
SharedBody::Port { interface, .. } => {
|
||||
if let Some(id) = interface {
|
||||
expect_element(p, &mut out, e.id, *id, "interface", |k| {
|
||||
k == Kind::Interface
|
||||
});
|
||||
} else {
|
||||
warning(
|
||||
&mut out,
|
||||
e.id,
|
||||
"missing_interface",
|
||||
"interface",
|
||||
"Choose a port interface or add an explicit provision/requirement contract.",
|
||||
);
|
||||
}
|
||||
}
|
||||
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
|
||||
SharedBody::Scenario {
|
||||
viewpoint,
|
||||
context,
|
||||
@@ -2140,82 +2126,7 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
for item in r.body.items() {
|
||||
expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem);
|
||||
}
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = &r.body
|
||||
{
|
||||
for (port, owner, outgoing, field) in [
|
||||
(*source_port, source, true, "source_port"),
|
||||
(*target_port, target, false, "target_port"),
|
||||
] {
|
||||
if let Some(port) = port {
|
||||
match p.elements.get(&port) {
|
||||
Some(e)
|
||||
if e.parent == Some(owner)
|
||||
&& matches!(e.shared(),Some(SharedBody::Port{direction,..})if if outgoing{*direction!=PortDirection::In}else{*direction!=PortDirection::Out}) =>
|
||||
{}
|
||||
_ => error(
|
||||
out,
|
||||
r.id,
|
||||
"functional_port",
|
||||
field,
|
||||
"Functional port must belong to the endpoint function with compatible direction.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.kind() == RelationKind::BoundaryExchange {
|
||||
for (e, outgoing, field) in [(a, true, "source"), (b, false, "target")] {
|
||||
match e.shared() {
|
||||
Some(SharedBody::Port { direction, .. })
|
||||
if e.parent
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| e.kind().is_performer())
|
||||
&& (if outgoing {
|
||||
*direction != PortDirection::In
|
||||
} else {
|
||||
*direction != PortDirection::Out
|
||||
}) => {}
|
||||
_ => error(
|
||||
out,
|
||||
r.id,
|
||||
"boundary_port",
|
||||
field,
|
||||
"Boundary exchange uses participant ports with output/input compatible direction.",
|
||||
),
|
||||
}
|
||||
}
|
||||
let interface_items = |port: &Element| {
|
||||
if let Some(SharedBody::Port {
|
||||
interface: Some(id),
|
||||
..
|
||||
}) = port.shared()
|
||||
&& let Some(SharedBody::Interface { items }) =
|
||||
p.elements.get(id).and_then(Element::shared)
|
||||
{
|
||||
return Some(items);
|
||||
}
|
||||
None
|
||||
};
|
||||
for port in [a, b] {
|
||||
if let Some(items) = interface_items(port) {
|
||||
for item in r.body.items() {
|
||||
if !items.contains(item) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"interface_contract",
|
||||
"items",
|
||||
"Exchange item is not included in a referenced port interface.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::ports::validate_port_relation(p, r, out);
|
||||
if let RelationBody::StateTransition {
|
||||
trigger,
|
||||
guard,
|
||||
@@ -2417,51 +2328,7 @@ pub fn allocation_summary(p: &Project, work: Id) -> Option<Id> {
|
||||
}
|
||||
|
||||
fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (function, port, field) in [
|
||||
(work.source(), boundary.source(), "source"),
|
||||
(work.target(), boundary.target(), "target"),
|
||||
] {
|
||||
let performer = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|a| a.kind() == RelationKind::Allocation && a.target() == function)
|
||||
.map(Relation::source);
|
||||
let owner = p.elements.get(&port).and_then(|e| e.parent);
|
||||
if let (Some(performer), Some(owner)) = (performer, owner) {
|
||||
if performer != owner {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"exchange_allocation_performer",
|
||||
field,
|
||||
"Boundary port owner must perform the corresponding work-exchange endpoint.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warning(
|
||||
out,
|
||||
r.id,
|
||||
"incomplete_exchange_allocation",
|
||||
field,
|
||||
"Allocate work endpoints to the boundary-port participants.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for item in work.body.items() {
|
||||
if !boundary.body.items().contains(item) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"exchange_allocation_items",
|
||||
"items",
|
||||
"The boundary exchange must carry every item of the allocated work exchange.",
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::ports::validate_exchange_allocation(p, r, out);
|
||||
}
|
||||
|
||||
pub fn effective_performer(p: &Project, work: Id) -> Option<Id> {
|
||||
@@ -2534,59 +2401,6 @@ fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
"A boundary exchange connects different participants.",
|
||||
);
|
||||
}
|
||||
let ports: Vec<_> = match &r.body {
|
||||
RelationBody::BoundaryExchange { source, target, .. } => vec![*source, *target],
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => [*source_port, *target_port].into_iter().flatten().collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
for port in ports {
|
||||
let mut interfaces = BTreeSet::new();
|
||||
if let Some(SharedBody::Port {
|
||||
interface: Some(id),
|
||||
..
|
||||
}) = p.elements.get(&port).and_then(Element::shared)
|
||||
{
|
||||
interfaces.insert(*id);
|
||||
}
|
||||
interfaces.extend(
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|rel| {
|
||||
matches!(
|
||||
rel.kind(),
|
||||
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
|
||||
) && rel.source() == port
|
||||
})
|
||||
.map(Relation::target),
|
||||
);
|
||||
if !interfaces.is_empty() {
|
||||
let allowed: BTreeSet<_> = interfaces
|
||||
.iter()
|
||||
.filter_map(|id| p.elements.get(id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect();
|
||||
for item in r.body.items() {
|
||||
if !allowed.contains(item) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"port_interface_items",
|
||||
"items",
|
||||
"The exchange carries an item outside a port's declared interface contract.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
r.kind(),
|
||||
RelationKind::Availability | RelationKind::StateAction
|
||||
|
||||
Reference in New Issue
Block a user