diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index ebb2d3ee..18083cd3 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -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> { parse_limited(source, ParseLimits::UNLIMITED) } pub fn parse_limited(source: &str, limits: ParseLimits) -> Result> { + parse_with_report_limited(source, limits).map(|decoded| decoded.project) +} +pub fn parse_with_report( + source: &str, +) -> Result> { + parse_with_report_limited(source, ParseLimits::UNLIMITED) +} +pub fn parse_with_report_limited( + source: &str, + limits: ParseLimits, +) -> Result> { 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 Result, locations: BTreeMap, fields: BTreeMap<(Id, String), usize>, } @@ -460,22 +481,31 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result>() })?; - 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::>(); 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 { 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, }) diff --git a/crates/oa-core/src/migration_v2/README.md b/crates/oa-core/src/migration_v2/README.md new file mode 100644 index 00000000..2a31ab7c --- /dev/null +++ b/crates/oa-core/src/migration_v2/README.md @@ -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. diff --git a/crates/oa-core/src/migration_v2/adapter.rs b/crates/oa-core/src/migration_v2/adapter.rs new file mode 100644 index 00000000..a7cd2edd --- /dev/null +++ b/crates/oa-core/src/migration_v2/adapter.rs @@ -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 { + 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), + }) +} diff --git a/crates/oa-core/src/migration_v2/mod.rs b/crates/oa-core/src/migration_v2/mod.rs new file mode 100644 index 00000000..ba05ca8b --- /dev/null +++ b/crates/oa-core/src/migration_v2/mod.rs @@ -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) +} diff --git a/crates/oa-core/src/migration_v2/model.rs b/crates/oa-core/src/migration_v2/model.rs new file mode 100644 index 00000000..ff5da7b8 --- /dev/null +++ b/crates/oa-core/src/migration_v2/model.rs @@ -0,0 +1,1611 @@ +// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3. +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; +use uuid::Uuid; + +pub type Id = Uuid; +pub const SCHEMA_VERSION: u32 = 2; +pub const MAX_COORDINATE: f32 = 1_000_000.; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Layer { + Oa, + Sa, +} +impl fmt::Display for Layer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Oa => "OA", + Self::Sa => "SA", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Kind { + Entity, + Actor, + Role, + Activity, + Capability, + OperationalMission, + Process, + System, + SystemActor, + Function, + SystemCapability, + Mission, + FunctionalChain, + ExchangeItem, + DataType, + DataField, + Interface, + Port, + Scenario, + Lifeline, + Occurrence, + StateMachine, + Region, + State, + Mode, + Initial, + Final, + Choice, + Junction, + Fork, + Join, + ShallowHistory, + DeepHistory, + EntryPoint, + ExitPoint, + Terminate, + Event, + Constraint, +} +impl Kind { + pub const ALL: [Self; 38] = [ + Self::Entity, + Self::Actor, + Self::Role, + Self::Activity, + Self::Capability, + Self::OperationalMission, + Self::Process, + Self::System, + Self::SystemActor, + Self::Function, + Self::SystemCapability, + Self::Mission, + Self::FunctionalChain, + Self::ExchangeItem, + Self::DataType, + Self::DataField, + Self::Interface, + Self::Port, + Self::Scenario, + Self::Lifeline, + Self::Occurrence, + Self::StateMachine, + Self::Region, + Self::State, + Self::Mode, + Self::Initial, + Self::Final, + Self::Choice, + Self::Junction, + Self::Fork, + Self::Join, + Self::ShallowHistory, + Self::DeepHistory, + Self::EntryPoint, + Self::ExitPoint, + Self::Terminate, + Self::Event, + Self::Constraint, + ]; + pub fn label(self) -> &'static str { + match self { + Self::Entity => "Entity", + Self::Actor => "Actor", + Self::Role => "Role", + Self::Activity => "Activity", + Self::Capability => "Capability", + Self::OperationalMission => "Operational mission", + Self::Process => "Process", + Self::System => "System", + Self::SystemActor => "System actor", + Self::Function => "Function", + Self::SystemCapability => "System capability", + Self::Mission => "Mission", + Self::FunctionalChain => "Functional chain", + Self::ExchangeItem => "Exchange item", + Self::DataType => "Data type", + Self::DataField => "Data field", + Self::Interface => "Interface", + Self::Port => "Port", + Self::Scenario => "Scenario", + Self::Lifeline => "Lifeline", + Self::Occurrence => "Occurrence", + Self::StateMachine => "State machine", + Self::Region => "Region", + Self::State => "State", + Self::Mode => "Mode", + Self::Initial => "Initial", + Self::Final => "Final", + Self::Choice => "Choice", + Self::Junction => "Junction", + Self::Fork => "Fork", + Self::Join => "Join", + Self::ShallowHistory => "Shallow history", + Self::DeepHistory => "Deep history", + Self::EntryPoint => "Entry point", + Self::ExitPoint => "Exit point", + Self::Terminate => "Terminate", + Self::Event => "Event", + Self::Constraint => "Constraint", + } + } + pub fn is_performer(self) -> bool { + matches!( + self, + Self::Entity | Self::Actor | Self::System | Self::SystemActor + ) + } + pub fn is_work(self) -> bool { + matches!(self, Self::Activity | Self::Function) + } + pub fn is_sequence(self) -> bool { + matches!(self, Self::Process | Self::FunctionalChain | Self::Scenario) + } + pub fn is_state(self) -> bool { + self.state_kind().is_some() + } + pub fn state_kind(self) -> Option { + Some(match self { + Self::State => StateKind::State, + Self::Mode => StateKind::Mode, + Self::Initial => StateKind::Initial, + Self::Final => StateKind::Final, + Self::Choice => StateKind::Choice, + Self::Junction => StateKind::Junction, + Self::Fork => StateKind::Fork, + Self::Join => StateKind::Join, + Self::ShallowHistory => StateKind::ShallowHistory, + Self::DeepHistory => StateKind::DeepHistory, + Self::EntryPoint => StateKind::EntryPoint, + Self::ExitPoint => StateKind::ExitPoint, + Self::Terminate => StateKind::Terminate, + _ => return None, + }) + } + pub fn is_stable_state(self) -> bool { + matches!(self, Self::State | Self::Mode) + } + pub fn supports(self, layer: Layer) -> bool { + match self { + Self::Entity + | Self::Actor + | Self::Role + | Self::Activity + | Self::Capability + | Self::OperationalMission + | Self::Process => layer == Layer::Oa, + Self::System + | Self::SystemActor + | Self::Function + | Self::SystemCapability + | Self::Mission + | Self::FunctionalChain => layer == Layer::Sa, + _ => true, + } + } +} +impl fmt::Display for Kind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Position { + pub x: f32, + pub y: f32, +} +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Size { + pub width: f32, + pub height: f32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Element { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub parent: Option, + pub body: ElementBody, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ElementBody { + Oa(OaBody), + Sa(SaBody), +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum OaBody { + Entity, + Actor, + Role, + Activity, + Capability, + Mission, + Process { steps: Vec }, + Shared(SharedBody), +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SaBody { + System, + Actor, + Function, + Capability, + Mission, + FunctionalChain { steps: Vec }, + Shared(SharedBody), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExchangeItemKind { + Information, + Material, + Energy, + Event, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum DataTypeDefinition { + Boolean, + Integer, + Real, + String, + Enumeration { literals: Vec }, + Record, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum TypeRef { + Boolean, + Integer, + Real, + String, + Named(Id), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PortDirection { + In, + Out, + InOut, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StateKind { + State, + Mode, + Initial, + Final, + Choice, + Junction, + Fork, + Join, + ShallowHistory, + DeepHistory, + EntryPoint, + ExitPoint, + Terminate, +} +impl StateKind { + pub fn kind(self) -> Kind { + match self { + Self::State => Kind::State, + Self::Mode => Kind::Mode, + Self::Initial => Kind::Initial, + Self::Final => Kind::Final, + Self::Choice => Kind::Choice, + Self::Junction => Kind::Junction, + Self::Fork => Kind::Fork, + Self::Join => Kind::Join, + Self::ShallowHistory => Kind::ShallowHistory, + Self::DeepHistory => Kind::DeepHistory, + Self::EntryPoint => Kind::EntryPoint, + Self::ExitPoint => Kind::ExitPoint, + Self::Terminate => Kind::Terminate, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TimeEventKind { + After, + At, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum EventBody { + Signal { + items: Vec, + }, + Time { + kind: TimeEventKind, + expression: String, + }, + Change { + expression: String, + }, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TransitionKind { + #[default] + External, + Local, + Internal, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StateActionKind { + Entry, + Do, + Exit, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FragmentOperator { + Alternative, + Parallel, + Loop, + Optional, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ControlKind { + Choice, + Merge, + Fork, + Join, + Loop, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum ScenarioViewpoint { + OperationalActivity, + ParticipantInteraction, + Functional, + BoundaryInterface, + #[default] + MixedLegacy, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub enum MessageKind { + #[default] + Asynchronous, + Call, + Reply, + Create, + Delete, + Timer { + expression: String, + }, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LifelineBinding { + pub referenced: Id, + pub local: Id, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MessageValue { + pub field: Id, + pub expression: String, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum OccurrenceBody { + Exchange { + exchange: Option, + sender: Option, + receiver: Option, + #[serde(default)] + message_kind: MessageKind, + #[serde(default)] + reply_to: Option, + #[serde(default)] + values: Vec, + }, + Action { + action: Option, + lifeline: Option, + }, + Control { + kind: ControlKind, + }, + SequenceReference { + sequence: Option, + }, + ScenarioUse { + scenario: Option, + bindings: Vec, + }, + State { + state: Option, + lifeline: Option, + }, + Fragment { + operator: FragmentOperator, + branches: Vec, + }, + Branch { + guard: String, + occurrences: Vec, + }, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SharedBody { + ExchangeItem { + kind: ExchangeItemKind, + }, + DataType { + definition: DataTypeDefinition, + }, + DataField { + type_ref: TypeRef, + minimum: u32, + maximum: Option, + unit: String, + }, + Interface { + items: Vec, + }, + Port { + direction: PortDirection, + interface: Option, + }, + Scenario { + #[serde(default)] + viewpoint: ScenarioViewpoint, + context: Option, + precondition: String, + postcondition: String, + steps: Vec, + }, + Lifeline { + represented: Option, + }, + Occurrence { + body: OccurrenceBody, + precondition: String, + postcondition: String, + }, + StateMachine, + Event { + body: EventBody, + }, + Region, + State { + kind: StateKind, + entry: String, + exit: String, + do_activity: String, + }, + Constraint { + expression: String, + constrained: Vec, + }, +} +impl Element { + pub fn layer(&self) -> Layer { + self.body.layer() + } + pub fn kind(&self) -> Kind { + self.body.kind() + } + pub fn shared(&self) -> Option<&SharedBody> { + self.body.shared() + } + pub fn shared_mut(&mut self) -> Option<&mut SharedBody> { + self.body.shared_mut() + } + pub fn steps(&self) -> Option<&Vec> { + self.body.steps() + } + pub fn steps_mut(&mut self) -> Option<&mut Vec> { + self.body.steps_mut() + } +} +impl ElementBody { + pub fn layer(&self) -> Layer { + match self { + Self::Oa(_) => Layer::Oa, + Self::Sa(_) => Layer::Sa, + } + } + pub fn shared(&self) -> Option<&SharedBody> { + match self { + Self::Oa(OaBody::Shared(v)) | Self::Sa(SaBody::Shared(v)) => Some(v), + _ => None, + } + } + pub fn shared_mut(&mut self) -> Option<&mut SharedBody> { + match self { + Self::Oa(OaBody::Shared(v)) | Self::Sa(SaBody::Shared(v)) => Some(v), + _ => None, + } + } + pub fn steps(&self) -> Option<&Vec> { + match self { + Self::Oa(OaBody::Process { steps }) | Self::Sa(SaBody::FunctionalChain { steps }) => { + Some(steps) + } + _ => match self.shared() { + Some(SharedBody::Scenario { steps, .. }) => Some(steps), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { occurrences, .. }, + .. + }) => Some(occurrences), + _ => None, + }, + } + } + pub fn steps_mut(&mut self) -> Option<&mut Vec> { + match self { + Self::Oa(OaBody::Process { steps }) | Self::Sa(SaBody::FunctionalChain { steps }) => { + Some(steps) + } + _ => match self.shared_mut() { + Some(SharedBody::Scenario { steps, .. }) => Some(steps), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { occurrences, .. }, + .. + }) => Some(occurrences), + _ => None, + }, + } + } + pub fn kind(&self) -> Kind { + match self { + Self::Oa(v) => match v { + OaBody::Entity => Kind::Entity, + OaBody::Actor => Kind::Actor, + OaBody::Role => Kind::Role, + OaBody::Activity => Kind::Activity, + OaBody::Capability => Kind::Capability, + OaBody::Mission => Kind::OperationalMission, + OaBody::Process { .. } => Kind::Process, + OaBody::Shared(s) => s.kind(), + }, + Self::Sa(v) => match v { + SaBody::System => Kind::System, + SaBody::Actor => Kind::SystemActor, + SaBody::Function => Kind::Function, + SaBody::Capability => Kind::SystemCapability, + SaBody::Mission => Kind::Mission, + SaBody::FunctionalChain { .. } => Kind::FunctionalChain, + SaBody::Shared(s) => s.kind(), + }, + } + } + pub fn shared_in(layer: Layer, body: SharedBody) -> Self { + match layer { + Layer::Oa => Self::Oa(OaBody::Shared(body)), + Layer::Sa => Self::Sa(SaBody::Shared(body)), + } + } + pub fn new(kind: Kind, layer: Layer) -> Option { + if !kind.supports(layer) { + return None; + } + Some(match kind { + Kind::Entity => Self::Oa(OaBody::Entity), + Kind::Actor => Self::Oa(OaBody::Actor), + Kind::Role => Self::Oa(OaBody::Role), + Kind::Activity => Self::Oa(OaBody::Activity), + Kind::Capability => Self::Oa(OaBody::Capability), + Kind::OperationalMission => Self::Oa(OaBody::Mission), + Kind::Process => Self::Oa(OaBody::Process { steps: vec![] }), + Kind::System => Self::Sa(SaBody::System), + Kind::SystemActor => Self::Sa(SaBody::Actor), + Kind::Function => Self::Sa(SaBody::Function), + Kind::SystemCapability => Self::Sa(SaBody::Capability), + Kind::Mission => Self::Sa(SaBody::Mission), + Kind::FunctionalChain => Self::Sa(SaBody::FunctionalChain { steps: vec![] }), + k => Self::shared_in( + layer, + match k { + Kind::ExchangeItem => SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + }, + Kind::DataType => SharedBody::DataType { + definition: DataTypeDefinition::String, + }, + Kind::DataField => SharedBody::DataField { + type_ref: TypeRef::String, + minimum: 1, + maximum: Some(1), + unit: String::new(), + }, + Kind::Interface => SharedBody::Interface { items: vec![] }, + Kind::Port => SharedBody::Port { + direction: PortDirection::InOut, + interface: None, + }, + Kind::Scenario => SharedBody::Scenario { + viewpoint: if layer == Layer::Oa { + ScenarioViewpoint::OperationalActivity + } else { + ScenarioViewpoint::Functional + }, + context: None, + precondition: String::new(), + postcondition: String::new(), + steps: vec![], + }, + Kind::Lifeline => SharedBody::Lifeline { represented: None }, + Kind::Occurrence => SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }, + Kind::StateMachine => SharedBody::StateMachine, + Kind::Event => SharedBody::Event { + body: EventBody::Signal { items: vec![] }, + }, + Kind::Region => SharedBody::Region, + k if k.is_state() => SharedBody::State { + kind: k.state_kind().expect("state kind"), + entry: String::new(), + exit: String::new(), + do_activity: String::new(), + }, + Kind::Constraint => SharedBody::Constraint { + expression: String::new(), + constrained: vec![], + }, + _ => unreachable!(), + }, + ), + }) + } +} +impl SharedBody { + pub fn kind(&self) -> Kind { + match self { + Self::ExchangeItem { .. } => Kind::ExchangeItem, + Self::DataType { .. } => Kind::DataType, + Self::DataField { .. } => Kind::DataField, + Self::Interface { .. } => Kind::Interface, + Self::Port { .. } => Kind::Port, + Self::Scenario { .. } => Kind::Scenario, + Self::Lifeline { .. } => Kind::Lifeline, + Self::Occurrence { .. } => Kind::Occurrence, + Self::StateMachine => Kind::StateMachine, + Self::Event { .. } => Kind::Event, + Self::Region => Kind::Region, + Self::State { kind, .. } => kind.kind(), + Self::Constraint { .. } => Kind::Constraint, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RelationKind { + Allocation, + Interaction, + CapabilityInvolvement, + ChainInvolvement, + RoleRealization, + RoleAssignment, + FunctionalExchange, + BoundaryExchange, + MissionCapability, + CapabilityInclude, + CapabilityExtend, + CapabilityGeneralization, + MissionInvolvement, + SequenceLink, + StateTransition, + Availability, + ExchangeAllocation, + InterfaceProvision, + InterfaceRequirement, + StateAction, +} +impl RelationKind { + pub const ALL: [Self; 20] = [ + Self::Allocation, + Self::Interaction, + Self::CapabilityInvolvement, + Self::ChainInvolvement, + Self::RoleRealization, + Self::RoleAssignment, + Self::FunctionalExchange, + Self::BoundaryExchange, + Self::MissionCapability, + Self::CapabilityInclude, + Self::CapabilityExtend, + Self::CapabilityGeneralization, + Self::MissionInvolvement, + Self::SequenceLink, + Self::StateTransition, + Self::Availability, + Self::ExchangeAllocation, + Self::InterfaceProvision, + Self::InterfaceRequirement, + Self::StateAction, + ]; + pub fn permits(self, from: Kind, to: Kind) -> bool { + match self { + Self::Allocation => from.is_performer() && to.is_work(), + Self::Interaction => from == Kind::Activity && to == Kind::Activity, + Self::CapabilityInvolvement => { + (from.is_performer() || from.is_work() || from == Kind::Role) + && matches!(to, Kind::Capability | Kind::SystemCapability) + } + Self::ChainInvolvement => { + matches!(from, Kind::Process | Kind::FunctionalChain) + && matches!(to, Kind::Capability | Kind::SystemCapability) + } + Self::RoleRealization => matches!(from, Kind::Entity | Kind::Actor) && to == Kind::Role, + Self::RoleAssignment => from == Kind::Role && to == Kind::Activity, + Self::FunctionalExchange => from == Kind::Function && to == Kind::Function, + Self::BoundaryExchange => from == Kind::Port && to == Kind::Port, + Self::MissionCapability => matches!( + (from, to), + (Kind::OperationalMission, Kind::Capability) + | (Kind::Mission, Kind::SystemCapability) + ), + Self::CapabilityInclude | Self::CapabilityExtend | Self::CapabilityGeneralization => { + matches!( + (from, to), + (Kind::Capability, Kind::Capability) + | (Kind::SystemCapability, Kind::SystemCapability) + ) + } + Self::MissionInvolvement => matches!( + (from, to), + (Kind::Entity | Kind::Actor, Kind::OperationalMission) + | (Kind::System | Kind::SystemActor, Kind::Mission) + ), + Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence, + Self::StateTransition => from.is_state() && to.is_state(), + Self::ExchangeAllocation => false, + Self::InterfaceProvision | Self::InterfaceRequirement => { + (from.is_performer() || from == Kind::Port) && to == Kind::Interface + } + Self::StateAction => from.is_stable_state() && to.is_work(), + Self::Availability => { + (from.is_work() + || from.is_performer() + || matches!( + from, + Kind::Capability + | Kind::SystemCapability + | Kind::Process + | Kind::FunctionalChain + )) + && to.is_stable_state() + } + } + } + pub fn hint(self) -> &'static str { + match self { + Self::Allocation => "Performer → leaf activity/function", + Self::Interaction => "OA activity → OA activity", + Self::CapabilityInvolvement => "Participant/activity/function → capability", + Self::ChainInvolvement => "Process/chain → capability", + Self::RoleRealization => "OA entity/actor → role", + Self::RoleAssignment => "OA role → activity", + Self::FunctionalExchange => "SA function → SA function", + Self::BoundaryExchange => "Output port → input port", + Self::MissionCapability => "Mission → capability in the same perspective", + Self::CapabilityInclude => "Including capability → included capability", + Self::CapabilityExtend => { + "Extending capability → extended capability, under a condition" + } + Self::CapabilityGeneralization => "Specific capability → general capability", + Self::MissionInvolvement => "Participant → mission in the same perspective", + Self::SequenceLink => "Preceding → following contribution in the same process or chain", + Self::StateTransition => "State → state in one machine", + Self::ExchangeAllocation => { + "Boundary exchange → operational interaction / functional exchange" + } + Self::InterfaceProvision => "Participant/port → provided interface", + Self::InterfaceRequirement => "Participant/port → required interface", + Self::StateAction => "State/mode → activity/function", + Self::Availability => "Behavior/participant → state/mode", + } + } +} +impl fmt::Display for RelationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Allocation => "Allocation", + Self::Interaction => "Interaction", + Self::CapabilityInvolvement => "Capability involvement", + Self::ChainInvolvement => "Chain involvement", + Self::RoleRealization => "Role realization", + Self::RoleAssignment => "Role assignment", + Self::FunctionalExchange => "Functional exchange", + Self::BoundaryExchange => "Boundary exchange", + Self::MissionCapability => "Mission capability", + Self::CapabilityInclude => "Capability include", + Self::CapabilityExtend => "Capability extend", + Self::CapabilityGeneralization => "Capability generalization", + Self::MissionInvolvement => "Mission involvement", + Self::SequenceLink => "Sequence link", + Self::StateTransition => "State transition", + Self::ExchangeAllocation => "Exchange allocation", + Self::InterfaceProvision => "Interface provision", + Self::InterfaceRequirement => "Interface requirement", + Self::StateAction => "State action", + Self::Availability => "Availability", + }) + } +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Relation { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub body: RelationBody, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum RelationBody { + Allocation { + source: Id, + target: Id, + }, + Interaction { + source: Id, + target: Id, + items: Vec, + }, + CapabilityInvolvement { + source: Id, + target: Id, + }, + ChainInvolvement { + source: Id, + target: Id, + }, + RoleRealization { + source: Id, + target: Id, + }, + RoleAssignment { + source: Id, + target: Id, + }, + FunctionalExchange { + source: Id, + target: Id, + source_port: Option, + target_port: Option, + items: Vec, + }, + BoundaryExchange { + source: Id, + target: Id, + items: Vec, + }, + MissionCapability { + source: Id, + target: Id, + }, + CapabilityInclude { + source: Id, + target: Id, + }, + CapabilityExtend { + source: Id, + target: Id, + condition: String, + }, + CapabilityGeneralization { + source: Id, + target: Id, + }, + MissionInvolvement { + source: Id, + target: Id, + }, + SequenceLink { + source: Id, + target: Id, + guard: String, + }, + StateTransition { + source: Id, + target: Id, + #[serde(default)] + transition_kind: TransitionKind, + #[serde(default)] + triggers: Vec, + #[serde(default)] + effects: Vec, + trigger: String, + guard: String, + effect: String, + }, + Availability { + source: Id, + target: Id, + }, + ExchangeAllocation { + source: Id, + target: Id, + }, + InterfaceProvision { + source: Id, + target: Id, + }, + InterfaceRequirement { + source: Id, + target: Id, + }, + StateAction { + source: Id, + target: Id, + phase: StateActionKind, + }, +} +impl Relation { + pub fn kind(&self) -> RelationKind { + self.body.kind() + } + pub fn source(&self) -> Id { + self.body.endpoints().0 + } + pub fn target(&self) -> Id { + self.body.endpoints().1 + } +} +impl RelationBody { + pub fn kind(&self) -> RelationKind { + match self { + Self::Allocation { .. } => RelationKind::Allocation, + Self::Interaction { .. } => RelationKind::Interaction, + Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement, + Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement, + Self::RoleRealization { .. } => RelationKind::RoleRealization, + Self::RoleAssignment { .. } => RelationKind::RoleAssignment, + Self::FunctionalExchange { .. } => RelationKind::FunctionalExchange, + Self::BoundaryExchange { .. } => RelationKind::BoundaryExchange, + Self::MissionCapability { .. } => RelationKind::MissionCapability, + Self::CapabilityInclude { .. } => RelationKind::CapabilityInclude, + Self::CapabilityExtend { .. } => RelationKind::CapabilityExtend, + Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization, + Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement, + Self::SequenceLink { .. } => RelationKind::SequenceLink, + Self::StateTransition { .. } => RelationKind::StateTransition, + Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation, + Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision, + Self::InterfaceRequirement { .. } => RelationKind::InterfaceRequirement, + Self::StateAction { .. } => RelationKind::StateAction, + Self::Availability { .. } => RelationKind::Availability, + } + } + pub fn endpoints(&self) -> (Id, Id) { + match self { + Self::Allocation { source, target } + | Self::Interaction { source, target, .. } + | Self::CapabilityInvolvement { source, target } + | Self::ChainInvolvement { source, target } + | Self::RoleRealization { source, target } + | Self::RoleAssignment { source, target } + | Self::FunctionalExchange { source, target, .. } + | Self::BoundaryExchange { source, target, .. } + | Self::MissionCapability { source, target } + | Self::CapabilityInclude { source, target } + | Self::CapabilityExtend { source, target, .. } + | Self::CapabilityGeneralization { source, target } + | Self::MissionInvolvement { source, target } + | Self::SequenceLink { source, target, .. } + | Self::StateTransition { source, target, .. } + | Self::ExchangeAllocation { source, target } + | Self::InterfaceProvision { source, target } + | Self::InterfaceRequirement { source, target } + | Self::StateAction { source, target, .. } + | Self::Availability { source, target } => (*source, *target), + } + } + pub fn set_endpoints(&mut self, a: Id, b: Id) { + match self { + Self::Allocation { source, target } + | Self::Interaction { source, target, .. } + | Self::CapabilityInvolvement { source, target } + | Self::ChainInvolvement { source, target } + | Self::RoleRealization { source, target } + | Self::RoleAssignment { source, target } + | Self::FunctionalExchange { source, target, .. } + | Self::BoundaryExchange { source, target, .. } + | Self::MissionCapability { source, target } + | Self::CapabilityInclude { source, target } + | Self::CapabilityExtend { source, target, .. } + | Self::CapabilityGeneralization { source, target } + | Self::MissionInvolvement { source, target } + | Self::SequenceLink { source, target, .. } + | Self::StateTransition { source, target, .. } + | Self::ExchangeAllocation { source, target } + | Self::InterfaceProvision { source, target } + | Self::InterfaceRequirement { source, target } + | Self::StateAction { source, target, .. } + | Self::Availability { source, target } => { + *source = a; + *target = b; + } + } + } + pub fn items(&self) -> &[Id] { + match self { + Self::Interaction { items, .. } + | Self::FunctionalExchange { items, .. } + | Self::BoundaryExchange { items, .. } => items, + _ => &[], + } + } + pub fn new(kind: RelationKind, source: Id, target: Id) -> Self { + match kind { + RelationKind::Allocation => Self::Allocation { source, target }, + RelationKind::Interaction => Self::Interaction { + source, + target, + items: vec![], + }, + RelationKind::CapabilityInvolvement => Self::CapabilityInvolvement { source, target }, + RelationKind::ChainInvolvement => Self::ChainInvolvement { source, target }, + RelationKind::RoleRealization => Self::RoleRealization { source, target }, + RelationKind::RoleAssignment => Self::RoleAssignment { source, target }, + RelationKind::FunctionalExchange => Self::FunctionalExchange { + source, + target, + source_port: None, + target_port: None, + items: vec![], + }, + RelationKind::BoundaryExchange => Self::BoundaryExchange { + source, + target, + items: vec![], + }, + RelationKind::MissionCapability => Self::MissionCapability { source, target }, + RelationKind::CapabilityInclude => Self::CapabilityInclude { source, target }, + RelationKind::CapabilityExtend => Self::CapabilityExtend { + source, + target, + condition: String::new(), + }, + RelationKind::CapabilityGeneralization => { + Self::CapabilityGeneralization { source, target } + } + RelationKind::MissionInvolvement => Self::MissionInvolvement { source, target }, + RelationKind::SequenceLink => Self::SequenceLink { + source, + target, + guard: String::new(), + }, + RelationKind::StateTransition => Self::StateTransition { + source, + target, + transition_kind: TransitionKind::External, + triggers: vec![], + effects: vec![], + trigger: String::new(), + guard: String::new(), + effect: String::new(), + }, + RelationKind::ExchangeAllocation => Self::ExchangeAllocation { source, target }, + RelationKind::InterfaceProvision => Self::InterfaceProvision { source, target }, + RelationKind::InterfaceRequirement => Self::InterfaceRequirement { source, target }, + RelationKind::StateAction => Self::StateAction { + source, + target, + phase: StateActionKind::Do, + }, + RelationKind::Availability => Self::Availability { source, target }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProjectionKind { + Overview, + Decomposition, + Allocation, + Traceability, + FunctionalDataflow, + Capabilities, +} +impl ProjectionKind { + pub fn includes(self, kind: Kind) -> bool { + match self { + Self::Allocation => kind.is_performer() || kind.is_work(), + Self::FunctionalDataflow => kind.is_work(), + Self::Capabilities => { + kind.is_performer() + || matches!( + kind, + Kind::Role + | Kind::Capability + | Kind::OperationalMission + | Kind::Mission + | Kind::SystemCapability + ) + } + _ => true, + } + } +} +impl std::fmt::Display for ProjectionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Allocation => "Architecture · allocated work", + Self::FunctionalDataflow => "Functional dataflow", + Self::Capabilities => "Missions & capabilities", + Self::Decomposition => "Containment", + Self::Overview => "All relationships", + Self::Traceability => "OA ↔ SA traceability", + }) + } +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Diagram { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub layer: Layer, + pub projection: ProjectionKind, + #[serde(deserialize_with = "unique_id_map")] + pub nodes: BTreeMap, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodePlacement { + pub id: Id, + pub alias: String, + pub element: Id, + pub position: Position, + pub size: Size, + pub collapsed: bool, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ArtifactRelation { + References, + Satisfies, + Verifies, + Implements, + DerivedFrom, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactLink { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub owner: Id, + pub relation: ArtifactRelation, + pub uri: String, + pub revision: Option, + pub fragment: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TraceKind { + Refinement, + Participant, + Capability, + Mission, + Exchange, + Sequence, + Item, + Scenario, + State, + Manual, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TracedProperties { + pub name: String, + pub description: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TraceBaseline { + pub oa: TracedProperties, + pub sa: TracedProperties, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Trace { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub oa: Id, + pub sa: Id, + pub kind: TraceKind, + pub baseline: Option, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Project { + pub schema_version: u32, + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + #[serde(deserialize_with = "unique_id_map")] + pub elements: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub relations: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub diagrams: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub artifact_links: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub traces: BTreeMap, +} + +pub fn unique_id_map<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + struct Unique(std::marker::PhantomData); + impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Unique { + type Value = BTreeMap; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("unique UUID keys") + } + fn visit_map>( + self, + mut access: A, + ) -> Result { + let mut map = BTreeMap::new(); + while let Some((id, value)) = access.next_entry()? { + if map.insert(id, value).is_some() { + return Err(serde::de::Error::custom(format!( + "Duplicate object ID {id}" + ))); + } + } + Ok(map) + } + } + deserializer.deserialize_map(Unique(std::marker::PhantomData)) +} +pub fn reserved_alias(value: &str) -> bool { + matches!( + value, + "arcadia" + | "project" + | "id" + | "oa" + | "sa" + | "element" + | "relation" + | "diagram" + | "node" + | "artifact" + | "trace" + | "body" + | "alias" + | "name" + | "description" + | "parent" + | "from" + | "to" + | "entity" + | "actor" + | "activity" + | "capability" + | "process" + | "allocation" + | "interaction" + | "contribution" + | "realization" + | "position" + | "steps" + | "true" + | "false" + | "null" + ) +} +pub fn valid_alias(value: &str) -> bool { + let mut chars = value.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + && !reserved_alias(value) +} +impl Project { + pub fn empty() -> Self { + Self { + schema_version: SCHEMA_VERSION, + id: Id::new_v4(), + alias: "mission".into(), + name: "Untitled mission".into(), + description: String::new(), + elements: BTreeMap::new(), + relations: BTreeMap::new(), + diagrams: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + } + } + pub fn identities(&self) -> Vec<(Id, &str)> { + let mut out = vec![(self.id, self.alias.as_str())]; + out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str()))); + out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str()))); + out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str()))); + out.extend( + self.diagrams + .values() + .flat_map(|v| v.nodes.values()) + .map(|v| (v.id, v.alias.as_str())), + ); + out.extend( + self.artifact_links + .values() + .map(|v| (v.id, v.alias.as_str())), + ); + out.extend(self.traces.values().map(|v| (v.id, v.alias.as_str()))); + out + } + pub fn contains(&self, id: Id) -> bool { + self.identities().iter().any(|(i, _)| *i == id) + } + pub fn fresh_alias(&self, name: &str, fallback: &str, id: Id) -> String { + let used: BTreeSet<_> = self.identities().into_iter().map(|(_, a)| a).collect(); + let mut stem = String::new(); + for c in name.chars() { + if c.is_ascii_alphanumeric() { + stem.push(c.to_ascii_lowercase()) + } else if !stem.is_empty() && !stem.ends_with('_') { + stem.push('_') + } + } + let mut stem = stem.trim_end_matches('_').to_owned(); + if !valid_alias(&stem) { + stem = format!("{fallback}_{}", stem.trim_matches('_')); + } + if !valid_alias(&stem) { + stem = "item".into(); + } + if !used.contains(stem.as_str()) { + return stem; + } + let candidate = format!("{stem}_{}", &id.simple().to_string()[..8]); + if !used.contains(candidate.as_str()) { + return candidate; + } + let mut n = 2; + loop { + let value = format!("{candidate}_{n}"); + if !used.contains(value.as_str()) { + return value; + } + n += 1; + } + } + pub fn named(&self, id: Id) -> &str { + if id == self.id { + return &self.name; + } + if let Some(v) = self.elements.get(&id) { + return &v.name; + } + if let Some(v) = self.relations.get(&id) { + return &v.name; + } + if let Some(v) = self.traces.get(&id) { + return &v.name; + } + if let Some(v) = self.artifact_links.get(&id) { + return &v.name; + } + if let Some(v) = self.diagrams.get(&id) { + return &v.name; + } + "Missing object" + } + pub fn layer_of(&self, id: Id) -> Option { + let mut current = id; + let mut seen = BTreeSet::new(); + loop { + if !seen.insert(current) { + return None; + } + if let Some(e) = self.elements.get(¤t) { + return Some(e.layer()); + } + current = self.relations.get(¤t)?.source(); + } + } + pub fn properties(&self, id: Id) -> Option { + if let Some(e) = self.elements.get(&id) { + return Some(TracedProperties { + name: e.name.clone(), + description: e.description.clone(), + }); + } + self.relations.get(&id).map(|r| TracedProperties { + name: r.name.clone(), + description: r.description.clone(), + }) + } + pub fn children(&self, parent: Id) -> impl Iterator { + self.elements + .values() + .filter(move |e| e.parent == Some(parent)) + } + pub fn make_element( + &self, + kind: Kind, + layer: Layer, + name: &str, + parent: Option, + ) -> Option { + let id = Id::new_v4(); + let mut body = ElementBody::new(kind, layer)?; + if kind == Kind::Occurrence + && parent + .and_then(|id| self.elements.get(&id)) + .is_some_and(|owner| { + matches!( + owner.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { .. }, + .. + }) + ) + }) + { + body = ElementBody::shared_in( + layer, + SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: String::new(), + occurrences: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }, + ); + } + Some(Element { + id, + alias: self.fresh_alias(name, "element", id), + name: name.into(), + description: String::new(), + parent, + body, + }) + } + pub fn add_element( + &mut self, + kind: Kind, + layer: Layer, + name: &str, + parent: Option, + ) -> Option { + let e = self.make_element(kind, layer, name, parent)?; + let id = e.id; + self.elements.insert(id, e); + Some(id) + } + pub fn add(&mut self, kind: Kind, name: &str, x: f32, y: f32) -> Id { + let layer = if kind.supports(Layer::Oa) { + Layer::Oa + } else { + Layer::Sa + }; + let id = self + .add_element(kind, layer, name, None) + .expect("kind belongs to a layer"); + let diagram = self.ensure_diagram(layer); + self.place(diagram, id, Position { x, y }); + id + } + pub fn ensure_diagram(&mut self, layer: Layer) -> Id { + if let Some(d) = self + .diagrams + .values() + .find(|d| d.layer == layer && d.projection == ProjectionKind::Overview) + { + return d.id; + } + let id = Id::new_v4(); + let name = format!("{layer} overview"); + let alias = self.fresh_alias(&name, "diagram", id); + self.diagrams.insert( + id, + Diagram { + id, + alias, + name, + description: String::new(), + layer, + projection: ProjectionKind::Overview, + nodes: BTreeMap::new(), + }, + ); + id + } + pub fn place(&mut self, diagram: Id, element: Id, position: Position) -> Id { + if let Some(node) = self + .diagrams + .get(&diagram) + .and_then(|d| d.nodes.values().find(|n| n.element == element)) + { + return node.id; + } + let id = Id::new_v4(); + let alias = self.fresh_alias(&format!("view_{}", self.named(element)), "node", id); + let node = NodePlacement { + id, + alias, + element, + position, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + }; + if let Some(d) = self.diagrams.get_mut(&diagram) { + d.nodes.insert(id, node); + } + id + } + 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); + let mut body = RelationBody::new(kind, source, target); + if self + .elements + .get(&source) + .is_some_and(|e| e.kind() == Kind::EntryPoint) + && let RelationBody::StateTransition { + transition_kind, .. + } = &mut body + { + *transition_kind = TransitionKind::Local; + } + self.relations.insert( + id, + Relation { + id, + alias, + name: name.into(), + description: String::new(), + body, + }, + ); + id + } +} diff --git a/crates/oa-core/src/migration_v2/references.rs b/crates/oa-core/src/migration_v2/references.rs new file mode 100644 index 00000000..e363dedb --- /dev/null +++ b/crates/oa-core/src/migration_v2/references.rs @@ -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 { + 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 { + 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 { + 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| { + 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)); + } + } +} diff --git a/crates/oa-core/src/migration_v2/source.rs b/crates/oa-core/src/migration_v2/source.rs new file mode 100644 index 00000000..28814b10 --- /dev/null +++ b/crates/oa-core/src/migration_v2/source.rs @@ -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 { + static KINDS: std::sync::OnceLock> = 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, + offset: usize, +} +struct Parser2<'a> { + source: &'a str, + tokens: Vec, + cursor: usize, + value_depth: usize, + limits: ParseLimits, + semantic_error: Option, +} +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) -> 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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::, 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, 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, + limits: ParseLimits, +) -> Result> { + 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] + }) +} diff --git a/crates/oa-core/src/migration_v2/state_validation.rs b/crates/oa-core/src/migration_v2/state_validation.rs new file mode 100644 index 00000000..75b58396 --- /dev/null +++ b/crates/oa-core/src/migration_v2/state_validation.rs @@ -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 { + 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 { + vertex + .parent + .and_then(|region| p.elements.get(®ion)) + .and_then(|region| region.parent) +} +fn first_region(p: &Project, vertex: Id, owner: Id) -> Option { + 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 { + 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> = 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, +) { + 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, +) { + 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) { + 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) { + 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).", + ); + } + } +} diff --git a/crates/oa-core/src/migration_v2/transitions.rs b/crates/oa-core/src/migration_v2/transitions.rs new file mode 100644 index 00000000..f4d895db --- /dev/null +++ b/crates/oa-core/src/migration_v2/transitions.rs @@ -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, + } +} diff --git a/crates/oa-core/src/migration_v2/validation.rs b/crates/oa-core/src/migration_v2/validation.rs new file mode 100644 index 00000000..75a18a99 --- /dev/null +++ b/crates/oa-core/src/migration_v2/validation.rs @@ -0,0 +1,2726 @@ +// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3. +use super::model::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Severity { + Error, + Warning, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub severity: Severity, + pub element: Option, + pub code: String, + pub field: Option, + pub message: String, +} +fn issue( + out: &mut Vec, + severity: Severity, + id: Option, + code: &str, + field: &str, + message: impl Into, +) { + out.push(Issue { + severity, + element: id, + code: code.into(), + field: (!field.is_empty()).then(|| field.into()), + message: message.into(), + }); +} +pub(crate) fn error( + out: &mut Vec, + id: Id, + code: &str, + field: &str, + message: impl Into, +) { + issue(out, Severity::Error, Some(id), code, field, message); +} +pub(crate) fn warning( + out: &mut Vec, + id: Id, + code: &str, + field: &str, + message: impl Into, +) { + issue(out, Severity::Warning, Some(id), code, field, message); +} +fn text(out: &mut Vec, id: Id, field: &str, value: &str) { + if !valid_text(value) { + error( + out, + id, + "invalid_text", + field, + "Text contains a character forbidden by XML 1.0.", + ); + } +} +pub fn valid_text(value: &str) -> bool { + value.chars().all(|c|matches!(c,'\t'|'\n'|'\r'|'\u{20}'..='\u{d7ff}'|'\u{e000}'..='\u{fffd}'|'\u{10000}'..='\u{10ffff}')) +} +pub fn valid_uri_reference(uri: &str) -> bool { + if uri.is_empty() + || uri + .chars() + .any(|c| c.is_whitespace() || c.is_control() || c == '\\') + { + return false; + } + let bytes = uri.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() + || !bytes[i + 1].is_ascii_hexdigit() + || !bytes[i + 2].is_ascii_hexdigit() + { + return false; + } + i += 2; + } + i += 1; + } + true +} +pub fn parent_permits(parent: Kind, child: Kind) -> bool { + if child.is_state() { + return parent == Kind::Region; + } + match child { + Kind::Entity | Kind::Actor | Kind::Role => parent == Kind::Entity, + Kind::Activity => parent == Kind::Activity, + Kind::Function => parent == Kind::Function, + Kind::Capability => parent == Kind::Capability, + Kind::SystemCapability => parent == Kind::SystemCapability, + Kind::DataField => matches!(parent, Kind::ExchangeItem | Kind::DataType), + Kind::Port => parent.is_performer() || parent.is_work(), + Kind::Lifeline => parent == Kind::Scenario, + Kind::Occurrence => parent.is_sequence() || parent == Kind::Occurrence, + Kind::StateMachine => parent.is_performer(), + Kind::Region => parent == Kind::StateMachine || parent.is_stable_state(), + _ => false, + } +} +pub fn ancestor_of_kind(p: &Project, id: Id, kind: Kind) -> Option { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + return None; + } + let e = p.elements.get(&id)?; + if e.kind() == kind { + return Some(id); + } + current = e.parent; + } + None +} +fn expect_element( + p: &Project, + out: &mut Vec, + owner: Id, + reference: Id, + field: &str, + allowed: impl Fn(Kind) -> bool, +) { + match p.elements.get(&reference) { + Some(e) if allowed(e.kind()) && p.layer_of(owner) == Some(e.layer()) => {} + _ => error( + out, + owner, + "invalid_reference_kind", + field, + format!("{field} must reference an existing compatible element in the same layer."), + ), + } +} +fn optional_element( + p: &Project, + out: &mut Vec, + owner: Id, + id: Option, + field: &str, + allowed: impl Fn(Kind) -> bool, +) { + if let Some(id) = id { + expect_element(p, out, owner, id, field, allowed) + } else { + warning( + out, + owner, + "incomplete_reference", + field, + format!("Choose {field}."), + ); + } +} +fn check_ids(out: &mut Vec, owner: Id, field: &str, ids: &[Id]) { + let mut seen = BTreeSet::new(); + for id in ids { + if !seen.insert(id) { + error( + out, + owner, + "duplicate_reference", + field, + format!( + "{field} repeats identity {id}; create distinct occurrence objects for repeated execution." + ), + ); + } + } +} + +pub fn validate(p: &Project) -> Vec { + let mut out = Vec::new(); + if p.schema_version != SCHEMA_VERSION { + error( + &mut out, + p.id, + "unsupported_schema", + "schema_version", + format!( + "Unsupported schema {} (expected {SCHEMA_VERSION})", + p.schema_version + ), + ); + } + let mut ids = BTreeSet::new(); + let mut aliases = BTreeMap::new(); + for (id, alias) in p.identities() { + if !ids.insert(id) { + error( + &mut out, + id, + "duplicate_identity", + "id", + "All model objects share one UUID namespace.", + ); + } + if !valid_alias(alias) { + error( + &mut out, + id, + "invalid_alias", + "alias", + format!("Invalid or reserved alias '{alias}'."), + ); + } + if let Some(other) = aliases.insert(alias, id) { + error( + &mut out, + id, + "duplicate_alias", + "alias", + format!("Alias '{alias}' also belongs to {other}."), + ); + } + } + text(&mut out, p.id, "name", &p.name); + text(&mut out, p.id, "description", &p.description); + if p.name.trim().is_empty() { + warning( + &mut out, + p.id, + "unnamed", + "name", + "Give the project a descriptive name.", + ); + } + if p.elements + .values() + .filter(|e| e.kind() == Kind::System) + .count() + > 1 + { + error( + &mut out, + p.id, + "multiple_systems", + "elements", + "A single SA analysis has at most one system of interest.", + ); + } + if p.elements.values().any(|e| e.layer() == Layer::Sa) + && !p.elements.values().any(|e| e.kind() == Kind::System) + { + warning( + &mut out, + p.id, + "missing_system", + "elements", + "Identify the SA system of interest.", + ); + } + for (key, e) in &p.elements { + if *key != e.id { + error( + &mut out, + e.id, + "map_identity", + "id", + "Element ID does not match its map key.", + ); + } + text(&mut out, e.id, "name", &e.name); + text(&mut out, e.id, "description", &e.description); + if e.name.trim().is_empty() { + warning( + &mut out, + e.id, + "unnamed", + "name", + "Give this element a name.", + ); + } + if let Some(parent) = e.parent { + match p.elements.get(&parent) { + Some(owner) + if parent != e.id + && owner.layer() == e.layer() + && parent_permits(owner.kind(), e.kind()) => {} + _ => error( + &mut out, + e.id, + "invalid_parent", + "parent", + "Parent must be a compatible owner in the same layer.", + ), + } + } else if e.kind().is_state() + || matches!( + e.kind(), + Kind::DataField | Kind::Port | Kind::Lifeline | Kind::Occurrence | Kind::Region + ) + { + error( + &mut out, + e.id, + "missing_parent", + "parent", + "This kind requires a structural owner.", + ); + } + let mut current = e.parent; + let mut seen = BTreeSet::from([e.id]); + while let Some(id) = current { + if !seen.insert(id) { + error( + &mut out, + e.id, + "containment_cycle", + "parent", + "Containment must be acyclic.", + ); + break; + } + current = p.elements.get(&id).and_then(|e| e.parent); + } + for id in super::references::element_references(e) { + if !p.contains(id) { + error( + &mut out, + e.id, + "dangling_reference", + "body", + format!("Reference {id} does not exist."), + ); + } + } + if e.kind().is_work() + && !p.children(e.id).any(|child| child.kind() == e.kind()) + && effective_performer(p, e.id).is_none() + { + warning( + &mut out, + e.id, + "unallocated_leaf", + "allocation", + format!("Allocate leaf '{}' to a performer.", e.name), + ); + } + if matches!(e.kind(), Kind::Capability | Kind::SystemCapability) + && !p.relations.values().any(|r| { + matches!( + r.kind(), + RelationKind::CapabilityInvolvement | RelationKind::ChainInvolvement + ) && r.target() == e.id + }) + { + warning( + &mut out, + e.id, + "unsupported_capability", + "involvement", + "Connect the capability to involved participants, work, or a process/chain.", + ); + } + if let Some(steps) = e.steps() { + check_ids(&mut out, e.id, "steps", steps); + for step in steps { + match p.elements.get(step) { + Some(occ) if occ.kind() == Kind::Occurrence && occ.parent == Some(e.id) => {} + _ => error( + &mut out, + e.id, + "invalid_occurrence", + "steps", + "Ordered steps must reference occurrences owned by this sequence.", + ), + } + } + if e.kind().is_sequence() && steps.is_empty() { + warning( + &mut out, + e.id, + "empty_sequence", + "steps", + "Add ordered occurrences.", + ); + } + validate_sequence(p, e, steps, &mut out); + } + if let Some(shared) = e.shared() { + match shared { + SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals }, + } => { + let mut seen = BTreeSet::new(); + for literal in literals { + text(&mut out, e.id, "literals", literal); + if !seen.insert(literal) { + error( + &mut out, + e.id, + "duplicate_literal", + "literals", + "Enumeration literals must be unique.", + ); + } + } + } + SharedBody::DataField { + type_ref, + minimum, + maximum, + unit, + } => { + if maximum.is_some_and(|max| max < *minimum) { + error( + &mut out, + e.id, + "invalid_multiplicity", + "maximum", + "Maximum multiplicity must be at least minimum, or unbounded.", + ); + } + text(&mut out, e.id, "unit", unit); + if let TypeRef::Named(id) = type_ref { + expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType); + } + if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id)) + && parent.kind() == Kind::DataType + && !matches!( + parent.shared(), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Record + }) + ) + { + error( + &mut out, + e.id, + "field_owner", + "parent", + "Fields under a data type require a Record type.", + ); + } + } + SharedBody::Interface { items } => { + check_ids(&mut out, e.id, "items", items); + for id in items { + expect_element(p, &mut out, e.id, *id, "items", |k| { + k == Kind::ExchangeItem + }); + } + } + SharedBody::Port { 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::Scenario { + viewpoint, + context, + precondition, + postcondition, + .. + } => { + match viewpoint { + ScenarioViewpoint::MixedLegacy => warning( + &mut out, + e.id, + "legacy_scenario_viewpoint", + "viewpoint", + "Choose a scenario viewpoint after reviewing the legacy lifelines and messages.", + ), + ScenarioViewpoint::OperationalActivity + | ScenarioViewpoint::ParticipantInteraction + if e.layer() != Layer::Oa => + { + error( + &mut out, + e.id, + "scenario_viewpoint_layer", + "viewpoint", + "Operational scenario viewpoints belong to OA.", + ) + } + ScenarioViewpoint::Functional | ScenarioViewpoint::BoundaryInterface + if e.layer() != Layer::Sa => + { + error( + &mut out, + e.id, + "scenario_viewpoint_layer", + "viewpoint", + "System scenario viewpoints belong to SA.", + ) + } + _ => {} + } + if let Some(id) = context { + expect_element(p, &mut out, e.id, *id, "context", |k| { + k.is_sequence() + || k.is_work() + || k.is_performer() + || matches!( + k, + Kind::Capability + | Kind::SystemCapability + | Kind::OperationalMission + | Kind::Mission + ) + }); + } + text(&mut out, e.id, "precondition", precondition); + text(&mut out, e.id, "postcondition", postcondition); + } + SharedBody::Lifeline { represented } => { + optional_element(p, &mut out, e.id, *represented, "represented", |k| { + k.is_performer() || k.is_work() || k == Kind::Role + }); + if let Some(element) = represented.and_then(|id| p.elements.get(&id)) + && let Some(viewpoint) = e.parent.and_then(|id| scenario_viewpoint(p, id)) + && !viewpoint_represents(viewpoint, element.kind()) + { + error( + &mut out, + e.id, + "scenario_viewpoint_lifeline", + "represented", + "The represented object must match the scenario's work or participant viewpoint.", + ); + } + } + SharedBody::Occurrence { + body, + precondition, + postcondition, + } => { + text(&mut out, e.id, "precondition", precondition); + text(&mut out, e.id, "postcondition", postcondition); + validate_occurrence(p, e, body, &mut out); + validate_occurrence_context(p, e, body, &mut out); + } + SharedBody::StateMachine => { + if e.parent.is_none() { + warning( + &mut out, + e.id, + "machine_owner", + "parent", + "Assign a state-machine owner.", + ); + } + if !p.children(e.id).any(|c| c.kind() == Kind::Region) { + warning( + &mut out, + e.id, + "empty_machine", + "body", + "Add a region; sibling regions describe concurrent state configurations.", + ); + } + } + SharedBody::State { + entry, + exit, + do_activity, + .. + } => { + text(&mut out, e.id, "entry", entry); + text(&mut out, e.id, "exit", exit); + text(&mut out, e.id, "do_activity", do_activity); + if !e.kind().is_stable_state() + && (!entry.is_empty() || !exit.is_empty() || !do_activity.is_empty()) + { + warning( + &mut out, + e.id, + "pseudostate_behavior", + "body", + "Pseudostate entry/do/exit prose is retained for review; only State/Mode vertices execute state behaviors.", + ); + } + } + SharedBody::Event { body } => match body { + EventBody::Signal { items } => { + check_ids(&mut out, e.id, "items", items); + for item in items { + expect_element(p, &mut out, e.id, *item, "items", |kind| { + kind == Kind::ExchangeItem + }); + } + } + EventBody::Time { expression, .. } | EventBody::Change { expression } => { + text(&mut out, e.id, "expression", expression); + if expression.trim().is_empty() { + warning( + &mut out, + e.id, + "missing_event_expression", + "expression", + "Describe the time instant/duration or change condition for this event.", + ); + } + } + }, + SharedBody::Constraint { + expression, + constrained, + } => { + text(&mut out, e.id, "expression", expression); + check_ids(&mut out, e.id, "constrained", constrained); + if expression.trim().is_empty() { + warning( + &mut out, + e.id, + "empty_constraint", + "expression", + "Write the constraint expression.", + ); + } + for id in constrained { + if p.layer_of(*id) != Some(e.layer()) { + error( + &mut out, + e.id, + "constraint_layer", + "constrained", + "Constraints refer to semantic records in the same layer.", + ); + } + } + } + _ => {} + } + } + } + super::state_validation::validate_graph(p, &mut out); + for (key, r) in &p.relations { + if *key != r.id { + error( + &mut out, + r.id, + "map_identity", + "id", + "Relation ID does not match its map key.", + ); + } + text(&mut out, r.id, "name", &r.name); + text(&mut out, r.id, "description", &r.description); + validate_relation(p, r, &mut out); + validate_relation_context(p, r, &mut out); + } + for (key, d) in &p.diagrams { + if *key != d.id { + error( + &mut out, + d.id, + "map_identity", + "id", + "Diagram ID does not match its map key.", + ); + } + text(&mut out, d.id, "name", &d.name); + text(&mut out, d.id, "description", &d.description); + let mut elements = BTreeSet::new(); + for (key, n) in &d.nodes { + if *key != n.id { + error( + &mut out, + n.id, + "map_identity", + "id", + "Node ID does not match its map key.", + ); + } + if !elements.insert(n.element) { + error( + &mut out, + n.id, + "duplicate_placement", + "element", + "An element appears at most once in each diagram.", + ); + } + match p.elements.get(&n.element) { + Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => { + } + _ => error( + &mut out, + n.id, + "diagram_layer", + "element", + "Diagram nodes reference existing elements in the selected layer.", + ), + }; + for (value, field) in [ + (n.position.x, "x"), + (n.position.y, "y"), + (n.size.width, "width"), + (n.size.height, "height"), + ] { + if !value.is_finite() + || value.abs() > MAX_COORDINATE + || matches!(field, "width" | "height") && value <= 0. + { + error( + &mut out, + n.id, + "invalid_bounds", + field, + "Coordinates must be finite and bounded; width and height must be positive.", + ); + } + } + } + } + for (key, a) in &p.artifact_links { + if *key != a.id { + error( + &mut out, + a.id, + "map_identity", + "id", + "Artifact ID does not match its map key.", + ); + } + for (field, value) in [ + ("name", &a.name), + ("description", &a.description), + ("uri", &a.uri), + ] { + text(&mut out, a.id, field, value); + } + if let Some(v) = &a.revision { + text(&mut out, a.id, "revision", v); + } + if let Some(v) = &a.fragment { + text(&mut out, a.id, "fragment", v); + } + if !p.contains(a.owner) || a.owner == a.id { + error( + &mut out, + a.id, + "artifact_owner", + "owner", + "Artifact owner must be another existing model object.", + ); + } + if !valid_uri_reference(&a.uri) { + error( + &mut out, + a.id, + "invalid_uri", + "uri", + "Use a nonempty URI reference without whitespace, backslashes, or invalid percent escapes.", + ); + } + } + for (key, t) in &p.traces { + if *key != t.id { + error( + &mut out, + t.id, + "map_identity", + "id", + "Trace ID does not match its map key.", + ); + } + text(&mut out, t.id, "name", &t.name); + text(&mut out, t.id, "description", &t.description); + if p.layer_of(t.oa) != Some(Layer::Oa) || p.layer_of(t.sa) != Some(Layer::Sa) { + error( + &mut out, + t.id, + "trace_layers", + "endpoints", + "Traces have a live OA endpoint and a live SA endpoint in canonical order.", + ); + } else if !trace_permits(p, t) { + error( + &mut out, + t.id, + "trace_kinds", + "kind", + "Trace kind is incompatible with its endpoint kinds.", + ); + } + if let Some(b) = &t.baseline { + for props in [&b.oa, &b.sa] { + text(&mut out, t.id, "baseline.name", &props.name); + text(&mut out, t.id, "baseline.description", &props.description); + } + } + match super::transitions::trace_drift(p, t) { + super::transitions::Drift::Unchanged => {} + super::transitions::Drift::Unreviewed => warning( + &mut out, + t.id, + "trace_unreviewed", + "baseline", + "Review the linked OA/SA name and description values before propagating updates.", + ), + drift => warning( + &mut out, + t.id, + "trace_drift", + "baseline", + format!( + "Linked descriptions changed since review ({drift:?}); preview the transition to reconcile them." + ), + ), + } + if p.traces.values().any(|other| { + other.id != t.id && other.oa == t.oa && other.sa == t.sa && other.kind == t.kind + }) { + error( + &mut out, + t.id, + "duplicate_trace", + "endpoints", + "An identical trace mapping already exists.", + ); + } + } + out +} +fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec) { + if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return; + } + let links: Vec<_> = p + .relations + .values() + .filter(|relation| { + relation.kind() == RelationKind::SequenceLink + && p.elements.get(&relation.source()).and_then(|e| e.parent) == Some(e.id) + && p.elements.get(&relation.target()).and_then(|e| e.parent) == Some(e.id) + }) + .collect(); + let explicit_topology = !links.is_empty() + || steps.iter().any(|id| { + p.elements.get(id).is_some_and(|step| { + matches!( + step.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) + }); + if explicit_topology { + validate_topology(p, e, steps, &links, out); + return; + } + // Version 2 exchange-only lists preserve their established linear meaning. + let mut previous: Option<(usize, &Relation)> = None; + for (index, id) in steps.iter().enumerate() { + let exchange = p + .elements + .get(id) + .and_then(|o| match o.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) => *exchange, + _ => None, + }) + .and_then(|r| p.relations.get(&r)); + if let Some(r) = exchange { + if let Some((previous_index, prev)) = previous + && prev.target() != r.source() + { + warning( + out, + e.id, + "sequence_gap", + "steps", + format!( + "Process '{}': disconnected steps {} and {}. '{}' ends at '{}'; '{}' starts at '{}'.", + e.name, + previous_index + 1, + index + 1, + prev.name, + p.named(prev.target()), + r.name, + p.named(r.source()) + ), + ); + } + previous = Some((index, r)); + } else { + warning( + out, + e.id, + "incomplete_step", + "steps", + format!("Select an exchange for step {}.", index + 1), + ); + } + } +} + +fn reachable(starts: &[Id], graph: &BTreeMap>) -> BTreeSet { + let mut pending = starts.to_vec(); + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if seen.insert(id) { + pending.extend(graph.get(&id).into_iter().flatten().copied()); + } + } + seen +} + +fn validate_topology( + p: &Project, + sequence: &Element, + steps: &[Id], + links: &[&Relation], + out: &mut Vec, +) { + let mut outgoing: BTreeMap> = BTreeMap::new(); + let mut incoming: BTreeMap> = BTreeMap::new(); + for link in links { + outgoing + .entry(link.source()) + .or_default() + .push(link.target()); + incoming + .entry(link.target()) + .or_default() + .push(link.source()); + } + let count = |graph: &BTreeMap>, id| graph.get(&id).map_or(0, Vec::len); + let starts: Vec<_> = steps + .iter() + .copied() + .filter(|id| count(&incoming, *id) == 0) + .collect(); + let ends: Vec<_> = steps + .iter() + .copied() + .filter(|id| count(&outgoing, *id) == 0) + .collect(); + for (code, label, nodes) in [ + ("sequence_starts", "start", &starts), + ("sequence_ends", "end", &ends), + ] { + if nodes.len() != 1 { + warning( + out, + sequence.id, + code, + "steps", + format!( + "Process/chain '{}' has {} {} contributions; connect a clear entry/exit or review the intended independent paths.", + sequence.name, + nodes.len(), + label + ), + ); + } + } + let from_start = reachable(&starts, &outgoing); + let to_end = reachable(&ends, &incoming); + for id in steps { + let Some(step) = p.elements.get(id) else { + continue; + }; + let before = count(&incoming, *id); + let after = count(&outgoing, *id); + if steps.len() > 1 && before == 0 && after == 0 { + warning( + out, + *id, + "isolated_contribution", + "parent", + "Connect this contribution to its process/chain path.", + ); + } + if !from_start.contains(id) { + warning( + out, + *id, + "unreachable_contribution", + "parent", + "This contribution cannot be reached from a process/chain entry.", + ); + } + if !to_end.contains(id) { + warning( + out, + *id, + "nonterminating_contribution", + "parent", + "No process/chain exit is reachable from this contribution; review the intended loop.", + ); + } + let control = match step.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Control { kind }, + .. + }) => Some(*kind), + _ => None, + }; + if let Some(kind) = control { + let expected = match kind { + ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, + ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, + ControlKind::Loop => before >= 1 && after >= 2, + }; + if !expected { + let rule = match kind { + ControlKind::Choice | ControlKind::Fork => { + "one incoming and at least two outgoing links" + } + ControlKind::Merge | ControlKind::Join => { + "at least two incoming and one outgoing link" + } + ControlKind::Loop => { + "at least one incoming and two outgoing links (repeat and exit)" + } + }; + warning( + out, + *id, + "control_cardinality", + "body.kind", + format!( + "{kind:?} needs {rule}; currently {before} incoming and {after} outgoing." + ), + ); + } + if matches!(kind, ControlKind::Choice | ControlKind::Loop) { + for link in links.iter().filter(|link| link.source() == *id) { + if matches!(&link.body, RelationBody::SequenceLink { guard, .. } if guard.trim().is_empty()) + { + warning( + out, + link.id, + "missing_sequence_guard", + "guard", + "Describe the choice/loop condition for this path, or use an explicit 'else' guard.", + ); + } + } + } + } else if before > 1 || after > 1 { + warning( + out, + *id, + "ambiguous_sequence_branch", + "parent", + "Use an explicit choice/merge or fork/join control to distinguish alternative from parallel paths.", + ); + } + } +} + +fn sequence_reference_path(p: &Project, from: Id, to: Id) -> bool { + let mut graph: BTreeMap> = BTreeMap::new(); + for element in p.elements.values() { + if let Some(owner) = element.parent + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::SequenceReference { + sequence: Some(target), + }, + .. + }) = element.shared() + { + graph.entry(owner).or_default().push(*target); + } + } + reachable(&[from], &graph).contains(&to) +} + +fn scenario_viewpoint(p: &Project, scenario: Id) -> Option { + match p.elements.get(&scenario)?.shared()? { + SharedBody::Scenario { viewpoint, .. } => Some(*viewpoint), + _ => None, + } +} +/// Represented element families allowed by a declared scenario viewpoint. +/// Shared by validation and graphical lifeline selection; layers are checked separately. +pub fn viewpoint_represents(viewpoint: ScenarioViewpoint, kind: Kind) -> bool { + match viewpoint { + ScenarioViewpoint::OperationalActivity => kind == Kind::Activity, + ScenarioViewpoint::Functional => kind == Kind::Function, + ScenarioViewpoint::ParticipantInteraction => { + matches!(kind, Kind::Entity | Kind::Actor | Kind::Role) + } + ScenarioViewpoint::BoundaryInterface => matches!(kind, Kind::System | Kind::SystemActor), + ScenarioViewpoint::MixedLegacy => { + kind.is_work() || kind.is_performer() || kind == Kind::Role + } + } +} +fn represented(p: &Project, line: Id) -> Option { + match p.elements.get(&line)?.shared()? { + SharedBody::Lifeline { represented } => *represented, + _ => None, + } +} +/// Whether an explicit semantic representation agrees with an endpoint. +/// None means a missing reference or an unresolved concrete performer; it is not +/// permission to infer one participant from several role realizations. +pub fn semantic_representation_compatible( + p: &Project, + representation: Id, + semantic: Id, +) -> Option { + let represented = p.elements.get(&representation)?; + let endpoint = p.elements.get(&semantic)?; + if represented.layer() != endpoint.layer() { + return Some(false); + } + if representation == semantic { + return Some(true); + } + let realizes = |participant, role| { + p.relations.values().any(|r| { + r.kind() == RelationKind::RoleRealization + && r.source() == participant + && r.target() == role + }) + }; + if endpoint.kind().is_work() { + if represented.kind() == Kind::Role { + return Some(p.relations.values().any(|r| { + r.kind() == RelationKind::RoleAssignment + && r.source() == representation + && r.target() == semantic + })); + } + if !represented.kind().is_performer() { + return Some(false); + } + return effective_performer(p, semantic).map(|performer| performer == representation); + } + if endpoint.kind() == Kind::Port { + return endpoint.parent.map(|owner| { + owner == representation + || (represented.kind() == Kind::Role && realizes(owner, representation)) + }); + } + Some( + (represented.kind() == Kind::Role && realizes(semantic, representation)) + || (endpoint.kind() == Kind::Role && realizes(representation, semantic)), + ) +} +/// Compatibility shared by scenario validation and graphical lifeline pickers. +pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option { + semantic_representation_compatible(p, represented(p, line)?, endpoint) +} +/// Effective semantic direction, including a Reply that reuses its Call exchange. +/// An incomplete reply correlation and a local timer have no inferred endpoints. +pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> { + let OccurrenceBody::Exchange { + exchange: Some(exchange), + message_kind, + reply_to, + .. + } = body + else { + return None; + }; + if matches!(message_kind, MessageKind::Timer { .. }) { + return None; + } + let relation = p.relations.get(exchange)?; + if message_kind == &MessageKind::Reply { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + exchange: Some(call_exchange), + .. + }, + .. + }) = p.elements.get(&(*reply_to)?).and_then(Element::shared) + else { + return None; + }; + if call_exchange == exchange { + return Some((relation.target(), relation.source())); + } + } + Some(relation.body.endpoints()) +} +/// Only report order guaranteed by a common sequence or an enclosing branch path. +/// A call in one alternative does not become an unconditional call after the fragment. +fn guaranteed_before(p: &Project, before: Id, after: Id) -> bool { + let mut current = after; + let mut seen = BTreeSet::new(); + while seen.insert(current) { + let Some(parent) = p + .elements + .get(¤t) + .and_then(|e| e.parent) + .and_then(|id| p.elements.get(&id)) + else { + break; + }; + if let Some(steps) = parent.steps() + && let (Some(a), Some(b)) = ( + steps.iter().position(|id| *id == before), + steps.iter().position(|id| *id == current), + ) + { + return a < b; + } + current = parent.id; + } + false +} +/// Structural co-execution/order, distinct from call dominance. A conditional +/// branch can execute before a following sibling; distinct ALT operands cannot +/// execute together. Parallel operands can execute in either order. +fn possibly_before(p: &Project, before: Id, after: Id) -> bool { + let path = |id: Id| { + let mut path = vec![]; + let mut current = Some(id); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + return vec![]; + } + let Some(element) = p.elements.get(&id) else { + return vec![]; + }; + path.push(id); + if element.kind() == Kind::Scenario { + path.reverse(); + return path; + } + current = element.parent; + } + vec![] + }; + let a = path(before); + let b = path(after); + let common = a.iter().zip(&b).take_while(|(a, b)| a == b).count(); + if common == 0 || common == a.len() || common == b.len() { + return false; + } + let parent = &p.elements[&a[common - 1]]; + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { operator, branches }, + .. + }) = parent.shared() + { + return match operator { + FragmentOperator::Alternative | FragmentOperator::Optional => false, + FragmentOperator::Parallel => true, + FragmentOperator::Loop => { + matches!((branches.iter().position(|id|*id==a[common]),branches.iter().position(|id|*id==b[common])),(Some(a),Some(b))if a BTreeSet { + let mut loops = BTreeSet::new(); + let mut current = p.elements.get(&occurrence).and_then(|e| e.parent); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + let Some(element) = p.elements.get(&id) else { + break; + }; + if matches!( + element.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Loop, + .. + }, + .. + }) + ) { + loops.insert(id); + } + if element.kind() == Kind::Scenario { + break; + } + current = element.parent; + } + loops +} +/// Fields carried by an exchange, including fields of nested named records. +pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet { + let mut pending = relation.body.items().to_vec(); + let mut seen = BTreeSet::new(); + let mut fields = BTreeSet::new(); + while let Some(owner) = pending.pop() { + if !seen.insert(owner) { + continue; + } + for field in p.children(owner) { + if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() { + fields.insert(field.id); + if let TypeRef::Named(id) = type_ref { + pending.push(*id); + } + } + } + } + fields +} +fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool { + match body { + OccurrenceBody::Exchange { + sender, receiver, .. + } => *sender == Some(line) || *receiver == Some(line), + OccurrenceBody::Action { lifeline, .. } | OccurrenceBody::State { lifeline, .. } => { + *lifeline == Some(line) + } + OccurrenceBody::ScenarioUse { bindings, .. } => bindings.iter().any(|b| b.local == line), + _ => false, + } +} +fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec) { + let OccurrenceBody::Exchange { + message_kind, + exchange, + sender, + receiver, + reply_to, + values, + } = body + else { + return; + }; + let scenario = ancestor_of_kind(p, e.id, Kind::Scenario); + if scenario.is_none() && (message_kind != &MessageKind::Asynchronous || reply_to.is_some()) { + error( + out, + e.id, + "message_context", + "message_kind", + "Call/reply, lifecycle and timer messages belong to a scenario, not a process/chain exchange contribution.", + ); + } + if let Some(viewpoint) = scenario.and_then(|id| scenario_viewpoint(p, id)) + && let Some(relation) = exchange.and_then(|id| p.relations.get(&id)) + { + let permitted = match viewpoint { + ScenarioViewpoint::OperationalActivity => relation.kind() == RelationKind::Interaction, + ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange, + ScenarioViewpoint::ParticipantInteraction => matches!( + relation.kind(), + RelationKind::Interaction | RelationKind::BoundaryExchange + ), + ScenarioViewpoint::BoundaryInterface => matches!( + relation.kind(), + RelationKind::FunctionalExchange | RelationKind::BoundaryExchange + ), + ScenarioViewpoint::MixedLegacy => true, + }; + if !permitted { + error( + out, + e.id, + "scenario_viewpoint_exchange", + "exchange", + "Choose an exchange compatible with this scenario viewpoint.", + ); + } + } + if let MessageKind::Timer { expression } = message_kind { + text(out, e.id, "message_kind.expression", expression); + if expression.trim().is_empty() { + warning( + out, + e.id, + "missing_timer_expression", + "message_kind.expression", + "Describe the timer duration or deadline expression.", + ); + } + if exchange.is_some() + || !values.is_empty() + || receiver.is_some_and(|line| Some(line) != *sender) + { + error( + out, + e.id, + "timer_contract", + "body", + "A timer is local to its sender lifeline, has no global exchange or carried message values, and its receiver is absent or the same lifeline.", + ); + } + } + if message_kind == &MessageKind::Reply { + if let Some(call) = reply_to { + match p.elements.get(call).and_then(Element::shared) { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + exchange: call_exchange, + sender: call_sender, + receiver: call_receiver, + .. + }, + .. + }) if scenario.is_some() + && ancestor_of_kind(p, *call, Kind::Scenario) == scenario => + { + if !guaranteed_before(p, *call, e.id) { + error( + out, + e.id, + "reply_order", + "reply_to", + "The call must precede its reply on the same guaranteed branch path.", + ); + } + let call_loops = enclosing_loops(p, *call); + for repeated in enclosing_loops(p, e.id).difference(&call_loops) { + warning( + out, + e.id, + "reply_repetition", + "reply_to", + format!( + "Loop '{}' can repeat this reply without repeating its correlated call. Move the call into the same repeating scope or review an explicit at-most-once constraint.", + p.named(*repeated) + ), + ); + } + if sender.zip(*call_receiver).is_some_and(|(a, b)| a != b) + || receiver.zip(*call_sender).is_some_and(|(a, b)| a != b) + { + error( + out, + e.id, + "reply_lifelines", + "reply_to", + "Reply lifelines must reverse the correlated call's sender and receiver.", + ); + } + if let (Some(response), Some(request)) = ( + exchange.and_then(|id| p.relations.get(&id)), + call_exchange.and_then(|id| p.relations.get(&id)), + ) && response.id != request.id + && response.body.endpoints() != (request.target(), request.source()) + { + error( + out, + e.id, + "reply_exchange", + "exchange", + "A reply reuses the call exchange in reverse, or references an explicit reverse-direction exchange.", + ); + } + if p.elements.values().any(|other| other.id != e.id && possibly_before(p,other.id,e.id) + && matches!(other.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {message_kind:MessageKind::Reply,reply_to:Some(previous),..},..}) if previous == call)) { + error(out,e.id,"duplicate_reply","reply_to","Another reply to this call can execute earlier on the same path, including a conditional or parallel branch."); + } + } + _ => error( + out, + e.id, + "reply_call", + "reply_to", + "A reply references a synchronous call in the same scenario.", + ), + } + } else { + warning( + out, + e.id, + "missing_reply_call", + "reply_to", + "Choose the synchronous call answered by this reply.", + ); + } + } else if reply_to.is_some() { + error( + out, + e.id, + "unexpected_reply_reference", + "reply_to", + "Only a Reply message correlates to a call.", + ); + } + if matches!(message_kind, MessageKind::Create | MessageKind::Delete) + && let Some(line) = receiver + { + for other in p.elements.values().filter(|other| { + other.id != e.id && ancestor_of_kind(p, other.id, Kind::Scenario) == scenario + }) { + if let Some(SharedBody::Occurrence { body, .. }) = other.shared() + && occurrence_uses_lifeline(body, *line) + && (if message_kind == &MessageKind::Create { + possibly_before(p, other.id, e.id) + } else { + possibly_before(p, e.id, other.id) + }) + { + error( + out, + e.id, + "lifeline_lifetime", + "receiver", + if message_kind == &MessageKind::Create { + "The created lifeline can already be used earlier on this path, including a conditional or parallel branch." + } else { + "The deleted lifeline can be used later on this path, including a conditional or parallel branch." + }, + ); + } + } + if !enclosing_loops(p, e.id).is_empty() { + let complement = if message_kind == &MessageKind::Create { + MessageKind::Delete + } else { + MessageKind::Create + }; + let paired=p.elements.values().any(|other| other.id!=e.id && other.parent==e.parent + && matches!(other.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{message_kind,receiver:Some(target),..},..})if message_kind==&complement && target==line) + && (if message_kind==&MessageKind::Create {guaranteed_before(p,e.id,other.id)} else {guaranteed_before(p,other.id,e.id)})); + if !paired { + warning( + out, + e.id, + "lifeline_repetition", + "receiver", + if message_kind == &MessageKind::Create { + "This loop can recreate an existing lifeline. Pair creation with a later deletion in the same operand, or review the lifetime across iterations." + } else { + "This loop can use or delete an already destroyed lifeline on its next iteration. Pair deletion with an earlier creation in the same operand, or review the lifetime across iterations." + }, + ); + } + } + } + let allowed = exchange + .and_then(|id| p.relations.get(&id)) + .map(|r| carried_fields(p, r)); + let mut fields = BTreeSet::new(); + for value in values { + expect_element(p, out, e.id, value.field, "values.field", |kind| { + kind == Kind::DataField + }); + text(out, e.id, "values.expression", &value.expression); + if !fields.insert(value.field) { + error( + out, + e.id, + "duplicate_message_value", + "values", + "Each carried field has at most one contextual value annotation in a message.", + ); + } + if allowed + .as_ref() + .is_some_and(|allowed| !allowed.contains(&value.field)) + { + error( + out, + e.id, + "message_value_field", + "values.field", + "Value annotations must reference fields carried by this message's exchange, including nested record fields.", + ); + } + if value.expression.trim().is_empty() { + warning( + out, + e.id, + "missing_message_value", + "values.expression", + "Enter the contextual value expression for this field.", + ); + } + } +} +fn validate_scenario_use( + p: &Project, + e: &Element, + target: Option, + bindings: &[LifelineBinding], + out: &mut Vec, +) { + let local = ancestor_of_kind(p, e.id, Kind::Scenario); + if local.is_none() { + error( + out, + e.id, + "scenario_use_owner", + "parent", + "A scenario use belongs to a scenario or one of its branches.", + ); + } + optional_element(p, out, e.id, target, "scenario", |kind| { + kind == Kind::Scenario + }); + if let (Some(local), Some(target)) = (local, target) { + let mut graph: BTreeMap> = BTreeMap::new(); + for use_element in p.elements.values() { + if let Some(owner) = ancestor_of_kind(p, use_element.id, Kind::Scenario) + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::ScenarioUse { + scenario: Some(referenced), + .. + }, + .. + }) = use_element.shared() + { + graph.entry(owner).or_default().push(*referenced); + } + } + if reachable(&[target], &graph).contains(&local) { + error( + out, + e.id, + "recursive_scenario_use", + "scenario", + "A scenario cannot directly or transitively reuse itself; use an explicit loop fragment for repetition.", + ); + } + if let (Some(a), Some(b)) = (scenario_viewpoint(p, local), scenario_viewpoint(p, target)) + && a != b + && a != ScenarioViewpoint::MixedLegacy + && b != ScenarioViewpoint::MixedLegacy + { + error( + out, + e.id, + "scenario_use_viewpoint", + "scenario", + "A reused scenario must have the same viewpoint as its caller.", + ); + } + } + let mut referenced_lines = BTreeSet::new(); + let mut local_lines = BTreeSet::new(); + for binding in bindings { + for (line, owner, field) in [ + (binding.referenced, target, "bindings.referenced"), + (binding.local, local, "bindings.local"), + ] { + expect_element(p, out, e.id, line, field, |kind| kind == Kind::Lifeline); + if owner.is_none() || p.elements.get(&line).and_then(|e| e.parent) != owner { + error( + out, + e.id, + "scenario_binding_context", + field, + "Bind a lifeline from the referenced scenario to one in the local scenario.", + ); + } + } + if !referenced_lines.insert(binding.referenced) || !local_lines.insert(binding.local) { + error( + out, + e.id, + "duplicate_lifeline_binding", + "bindings", + "Scenario-use lifeline bindings are one-to-one.", + ); + } + if let (Some(a), Some(b)) = ( + represented(p, binding.referenced), + represented(p, binding.local), + ) && semantic_representation_compatible(p, b, a) == Some(false) + { + error( + out, + e.id, + "scenario_binding_identity", + "bindings", + "Bound lifelines represent the same semantic object, or an explicitly realized operational role.", + ); + } + } + if let Some(target) = target { + for line in p.children(target).filter(|e| e.kind() == Kind::Lifeline) { + if !referenced_lines.contains(&line.id) { + warning( + out, + e.id, + "missing_lifeline_binding", + "bindings", + format!( + "Bind referenced lifeline '{}' to its local role.", + line.name + ), + ); + } + } + } +} + +fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec) { + let scenario = ancestor_of_kind(p, e.id, Kind::Scenario); + let sequence = e + .parent + .and_then(|id| p.elements.get(&id)) + .filter(|owner| matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)); + let lifeline = |out: &mut Vec, id: Option, field: &str| { + if let Some(id) = id { + expect_element(p, out, e.id, id, field, |k| k == Kind::Lifeline); + if p.elements.get(&id).and_then(|l| l.parent) != scenario { + error( + out, + e.id, + "lifeline_context", + field, + "Lifeline must belong to the occurrence's scenario.", + ); + } + } else if scenario.is_some() { + warning( + out, + e.id, + "missing_lifeline", + field, + format!("Choose {field}."), + ); + } + }; + match body { + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + message_kind, + .. + } => { + if let Some(id) = exchange { + match p.relations.get(id) { + Some(r) + if p.layer_of(*id) == Some(e.layer()) + && matches!( + r.kind(), + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange + ) => {} + _ => error( + out, + e.id, + "occurrence_exchange", + "exchange", + "Occurrence must reference an exchange in the same layer.", + ), + } + } else if !matches!(message_kind, MessageKind::Timer { .. }) { + warning( + out, + e.id, + "missing_exchange", + "exchange", + "Choose an exchange.", + ); + } + lifeline(out, *sender, "sender"); + if !matches!(message_kind, MessageKind::Timer { .. }) || receiver.is_some() { + lifeline(out, *receiver, "receiver"); + } + validate_message(p, e, body, out); + if sequence.is_some() { + if sender.is_some() || receiver.is_some() { + error( + out, + e.id, + "process_lifeline", + "body", + "Process/chain exchange contributions do not use scenario lifelines.", + ); + } + if let Some(relation) = exchange.and_then(|id| p.relations.get(&id)) + && relation.kind() + != if e.layer() == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + } + { + error( + out, + e.id, + "process_exchange", + "exchange", + "Process/chain contributions reference a work exchange, not a boundary exchange.", + ); + } + } + } + OccurrenceBody::Action { + action, + lifeline: line, + } => { + optional_element(p, out, e.id, *action, "action", Kind::is_work); + lifeline(out, *line, "lifeline"); + if sequence.is_some() && line.is_some() { + error( + out, + e.id, + "process_lifeline", + "lifeline", + "A process/chain work involvement does not use a scenario lifeline.", + ); + } + } + OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. } => { + if let Some(owner) = sequence { + if let OccurrenceBody::SequenceReference { sequence } = body { + optional_element(p, out, e.id, *sequence, "sequence", |kind| { + kind == owner.kind() + }); + if let Some(target) = sequence + && sequence_reference_path(p, *target, owner.id) + { + error( + out, + e.id, + "recursive_sequence_reference", + "sequence", + "A process/chain cannot directly or transitively reuse itself.", + ); + } + } + } else { + error( + out, + e.id, + "sequence_contribution_owner", + "parent", + "Controls and reusable sequence contributions belong directly to a process or functional chain.", + ); + } + } + OccurrenceBody::State { + state, + lifeline: line, + } => { + optional_element(p, out, e.id, *state, "state", |k| { + matches!(k, Kind::State | Kind::Mode) + }); + lifeline(out, *line, "lifeline"); + } + OccurrenceBody::ScenarioUse { scenario, bindings } => { + validate_scenario_use(p, e, *scenario, bindings, out) + } + OccurrenceBody::Fragment { branches, .. } => { + check_ids(out, e.id, "branches", branches); + for id in branches { + match p.elements.get(id) { + Some(b) + if b.parent == Some(e.id) + && matches!( + b.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + ) => {} + _ => error( + out, + e.id, + "fragment_branch", + "branches", + "Fragments contain branch occurrences they own.", + ), + } + } + } + OccurrenceBody::Branch { guard, .. } => { + text(out, e.id, "guard", guard); + if !e + .parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|p| { + matches!( + p.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { .. }, + .. + }) + ) + }) + { + error( + out, + e.id, + "branch_owner", + "parent", + "A branch belongs to a fragment occurrence.", + ); + } + } + } + if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id)) + && matches!(parent.kind(), Kind::Process | Kind::FunctionalChain) + && !matches!( + body, + OccurrenceBody::Exchange { .. } + | OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. } + ) + { + error( + out, + e.id, + "process_occurrence", + "body", + "Processes and functional chains contain work, exchange, control, or reusable sequence contributions.", + ); + } +} +/// Iterative reachability keeps cyclic or deeply nested capability drafts safe. +/// Inclusion and specialization are separate graphs, not containment edges. +fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bool { + let mut pending = vec![from]; + let mut seen = BTreeSet::new(); + while let Some(current) = pending.pop() { + if current == to { + return true; + } + if seen.insert(current) { + pending.extend( + p.relations + .values() + .filter(|r| r.kind() == kind && r.source() == current) + .map(Relation::target), + ); + } + } + false +} + +fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { + let (source, target) = r.body.endpoints(); + for id in super::references::relation_references(r) { + if !p.contains(id) { + error( + out, + r.id, + "dangling_reference", + "body", + format!("Reference {id} does not exist."), + ); + } + } + if let RelationBody::ExchangeAllocation { .. } = r.body { + match (p.relations.get(&source), p.relations.get(&target)) { + (Some(a), Some(b)) + if a.kind() == RelationKind::BoundaryExchange + && matches!( + b.kind(), + RelationKind::FunctionalExchange | RelationKind::Interaction + ) + && p.layer_of(source) == p.layer_of(target) => {} + _ => error( + out, + r.id, + "exchange_allocation", + "endpoints", + "Exchange allocation relates a boundary exchange to a work exchange in the same layer.", + ), + }; + validate_exchange_allocation(p, r, out); + return; + } + let (Some(a), Some(b)) = (p.elements.get(&source), p.elements.get(&target)) else { + error( + out, + r.id, + "invalid_endpoints", + "endpoints", + "Relationship endpoints must be existing elements.", + ); + return; + }; + if a.layer() != b.layer() + || !r.kind().permits(a.kind(), b.kind()) + || (a.id == b.id && r.kind() != RelationKind::StateTransition) + { + error( + out, + r.id, + "invalid_endpoints", + "endpoints", + format!("Invalid {}: {} in one layer.", r.kind(), r.kind().hint()), + ); + } + if let RelationBody::SequenceLink { guard, .. } = &r.body { + text(out, r.id, "guard", guard); + let owner = a.parent.and_then(|id| p.elements.get(&id)); + if a.parent != b.parent + || !owner + .is_some_and(|owner| matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)) + { + error( + out, + r.id, + "sequence_link_context", + "endpoints", + "Sequence links connect contributions directly owned by the same process or functional chain.", + ); + } + let work = |element: &Element| match element.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { action, .. }, + .. + }) => *action, + _ => None, + }; + let exchange = |element: &Element| match element.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) => exchange.and_then(|id| p.relations.get(&id)), + _ => None, + }; + let mismatch = match (work(a), exchange(a), work(b), exchange(b)) { + (Some(work), _, _, Some(exchange)) => work != exchange.source(), + (_, Some(exchange), Some(work), _) => exchange.target() != work, + (_, Some(previous), _, Some(next)) => previous.target() != next.source(), + _ => false, + }; + if mismatch { + error( + out, + r.id, + "sequence_exchange_endpoint", + "endpoints", + "Adjacent work/exchange contributions must agree with the referenced exchange's source and target work.", + ); + } + } + if let RelationBody::CapabilityExtend { condition, .. } = &r.body { + text(out, r.id, "condition", condition); + if condition.trim().is_empty() { + warning( + out, + r.id, + "missing_extension_condition", + "condition", + "Describe when the extending capability adds behavior to the extended capability.", + ); + } + } + if matches!( + r.kind(), + RelationKind::CapabilityInclude | RelationKind::CapabilityGeneralization + ) && relation_path_exists(p, r.kind(), target, source) + { + let (code, message) = if r.kind() == RelationKind::CapabilityInclude { + ( + "capability_include_cycle", + "Capability inclusion must be acyclic; a capability cannot transitively include itself.", + ) + } else { + ( + "capability_generalization_cycle", + "Capability specialization must be acyclic; a capability cannot be its own ancestor.", + ) + }; + error(out, r.id, code, "target", message); + } + if r.kind() == RelationKind::Allocation { + if p.children(b.id).any(|child| child.kind() == b.kind()) { + error( + out, + r.id, + "allocation_leaf", + "target", + "Allocate leaf activities/functions; decomposition is independent of performer allocation.", + ); + } + if p.relations + .values() + .filter(|other| other.kind() == RelationKind::Allocation && other.target() == target) + .count() + > 1 + { + error( + out, + r.id, + "multiple_performers", + "target", + "A leaf activity/function has at most one direct performer.", + ); + } + } + if r.kind() == RelationKind::RoleAssignment { + if p.children(b.id).any(|child| child.kind() == b.kind()) { + error( + out, + r.id, + "role_leaf", + "target", + "Assign roles to leaf activities.", + ); + } + let performers: BTreeSet<_> = p + .relations + .values() + .filter(|rel| rel.kind() == RelationKind::RoleRealization && rel.target() == source) + .map(Relation::source) + .collect(); + if let Some(allocated) = p + .relations + .values() + .find(|rel| rel.kind() == RelationKind::Allocation && rel.target() == target) + && !performers.is_empty() + && !performers.contains(&allocated.source()) + { + warning( + out, + r.id, + "role_allocation_conflict", + "target", + "Direct performer does not realize the assigned role.", + ); + } + } + check_ids(out, r.id, "items", r.body.items()); + for item in r.body.items() { + expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem); + } + 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.", + ); + } + } + } + } + } + if let RelationBody::StateTransition { + trigger, + guard, + effect, + triggers, + effects, + .. + } = &r.body + { + text(out, r.id, "trigger", trigger); + text(out, r.id, "guard", guard); + text(out, r.id, "effect", effect); + check_ids(out, r.id, "triggers", triggers); + check_ids(out, r.id, "effects", effects); + for id in triggers { + expect_element(p, out, r.id, *id, "triggers", |kind| { + kind == Kind::Event || kind.is_work() + }); + } + for id in effects { + expect_element(p, out, r.id, *id, "effects", Kind::is_work); + } + if triggers.is_empty() && !trigger.trim().is_empty() { + warning( + out, + r.id, + "unresolved_trigger", + "trigger", + "This transition retains descriptive triggering, not completion. Choose a typed Event or work trigger to resolve it.", + ); + } + super::state_validation::validate_transition(p, r, a, b, out); + } +} +pub fn trace_permits(p: &Project, t: &Trace) -> bool { + if t.kind == TraceKind::Manual { + return p.properties(t.oa).is_some() && p.properties(t.sa).is_some(); + } + match (p.elements.get(&t.oa), p.elements.get(&t.sa)) { + (Some(a), Some(b)) => match t.kind { + TraceKind::Refinement => a.kind() == Kind::Activity && b.kind() == Kind::Function, + TraceKind::Participant => { + matches!(a.kind(), Kind::Entity | Kind::Actor) + && matches!(b.kind(), Kind::System | Kind::SystemActor) + } + TraceKind::Capability => { + a.kind() == Kind::Capability && b.kind() == Kind::SystemCapability + } + TraceKind::Mission => a.kind() == Kind::OperationalMission && b.kind() == Kind::Mission, + TraceKind::Sequence => { + (a.kind() == Kind::Process && b.kind() == Kind::FunctionalChain) + || (a.kind() == Kind::Occurrence + && b.kind() == Kind::Occurrence + && a.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind() == Kind::Process) + && b.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind() == Kind::FunctionalChain)) + } + TraceKind::Item => { + matches!( + a.kind(), + Kind::ExchangeItem + | Kind::DataType + | Kind::DataField + | Kind::Interface + | Kind::Port + ) && a.kind() == b.kind() + } + TraceKind::Scenario => { + matches!(a.kind(), Kind::Scenario | Kind::Lifeline | Kind::Occurrence) + && a.kind() == b.kind() + } + TraceKind::State => { + (a.kind().is_state() + || matches!(a.kind(), Kind::StateMachine | Kind::Region | Kind::Event)) + && a.kind() == b.kind() + } + _ => false, + }, + _ => { + matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange) + } + } +} + +/// Parent choices for a newly created object; editing also validates cycles. +pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option) -> bool { + if !child_kind.supports(layer) { + return false; + } + match parent { + None => { + !child_kind.is_state() + && !matches!( + child_kind, + Kind::DataField | Kind::Port | Kind::Lifeline | Kind::Occurrence | Kind::Region + ) + } + Some(id) => p.elements.get(&id).is_some_and(|owner| { + owner.layer() == layer + && parent_permits(owner.kind(), child_kind) + && match owner.shared() { + Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => { + *definition == DataTypeDefinition::Record + } + Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => { + matches!( + body, + OccurrenceBody::Fragment { .. } | OccurrenceBody::Branch { .. } + ) + } + _ => true, + } + }), + } +} +/// Uses the same relation rules as committed edits, without cloning a project. +pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> bool { + if kind == RelationKind::Allocation + && p.relations + .values() + .any(|r| r.kind() == kind && r.target() == target) + { + return false; + } + if kind == RelationKind::StateTransition + && p.elements + .get(&source) + .is_some_and(|e| e.kind() == Kind::Initial) + && p.relations + .values() + .any(|r| r.kind() == kind && r.source() == source) + { + return false; + } + let mut candidate = Relation { + id: Id::nil(), + alias: "candidate".into(), + name: String::new(), + description: String::new(), + body: RelationBody::new(kind, source, target), + }; + if p.elements + .get(&source) + .is_some_and(|e| e.kind() == Kind::EntryPoint) + && let RelationBody::StateTransition { + transition_kind, .. + } = &mut candidate.body + { + *transition_kind = TransitionKind::Local; + } + let mut issues = vec![]; + validate_relation(p, &candidate, &mut issues); + validate_relation_context(p, &candidate, &mut issues); + if kind == RelationKind::StateTransition { + for endpoint in [source, target] { + if let Some(e) = p.elements.get(&endpoint) { + super::state_validation::validate_vertex(p, e, Some(&candidate), &mut issues); + } + } + } + !issues.iter().any(|i| i.severity == Severity::Error) +} +/// Aggregate display information only; this never creates inherited allocations. +pub fn allocation_summary(p: &Project, work: Id) -> Option { + let root = p.elements.get(&work)?; + if !root.kind().is_work() { + return None; + } + let mut leaves = Vec::new(); + let mut pending = vec![work]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + return None; + } + let children: Vec<_> = p + .children(id) + .filter(|e| e.kind() == root.kind()) + .map(|e| e.id) + .collect(); + if children.is_empty() { + leaves.push(id); + } else { + pending.extend(children); + } + } + let mut performer = None; + for leaf in leaves { + let current = effective_performer(p, leaf)?; + if performer.is_some_and(|p| p != current) { + return None; + } + performer = Some(current); + } + performer +} + +fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec) { + 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.", + ); + } + } +} + +pub fn effective_performer(p: &Project, work: Id) -> Option { + let direct: Vec<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::Allocation && r.target() == work) + .map(Relation::source) + .collect(); + if direct.len() == 1 { + return Some(direct[0]); + } + if !direct.is_empty() { + return None; + } + let roles: BTreeSet<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == work) + .map(Relation::source) + .collect(); + let performers: BTreeSet<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target())) + .map(Relation::source) + .collect(); + (performers.len() == 1).then(|| *performers.first().unwrap()) +} + +fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec) { + if r.kind() == RelationKind::RoleAssignment + && !p + .relations + .values() + .any(|a| a.kind() == RelationKind::Allocation && a.target() == r.target()) + { + let roles: BTreeSet<_> = p + .relations + .values() + .filter(|a| a.kind() == RelationKind::RoleAssignment && a.target() == r.target()) + .map(Relation::source) + .collect(); + let performers: BTreeSet<_> = p + .relations + .values() + .filter(|a| a.kind() == RelationKind::RoleRealization && roles.contains(&a.target())) + .map(Relation::source) + .collect(); + if performers.len() > 1 { + warning( + out, + r.id, + "ambiguous_role_performer", + "target", + "Assigned roles are shared by several performers; no unique effective performer is inferred.", + ); + } + } + if r.kind() == RelationKind::BoundaryExchange + && let (Some(a), Some(b)) = (p.elements.get(&r.source()), p.elements.get(&r.target())) + && a.parent.is_some() + && a.parent == b.parent + { + error( + out, + r.id, + "boundary_owner", + "endpoints", + "A boundary exchange connects different participants.", + ); + } + 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 + ) { + let (state, behavior) = if r.kind() == RelationKind::Availability { + (r.target(), r.source()) + } else { + (r.source(), r.target()) + }; + let owner = ancestor_of_kind(p, state, Kind::StateMachine) + .and_then(|id| p.elements.get(&id)) + .and_then(|e| e.parent); + let performer = p.elements.get(&behavior).and_then(|e| { + if e.kind().is_performer() { + Some(e.id) + } else { + effective_performer(p, e.id) + } + }); + if let (Some(owner), Some(performer)) = (owner, performer) + && owner != performer + { + error( + out, + r.id, + "state_owner_context", + "endpoints", + "State availability/action must agree with the behavior's allocated participant.", + ); + } + } +} +fn validate_occurrence_context( + p: &Project, + e: &Element, + body: &OccurrenceBody, + out: &mut Vec, +) { + if let Some(owner) = e.parent.and_then(|id| p.elements.get(&id)) { + if owner.kind() == Kind::Occurrence { + match owner.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { .. }, + .. + }) if matches!(body, OccurrenceBody::Branch { .. }) => {} + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) if !matches!(body, OccurrenceBody::Branch { .. }) => {} + _ => error( + out, + e.id, + "occurrence_owner", + "parent", + "Nested occurrences belong to branches; branches belong to fragments.", + ), + } + } + if let Some(steps) = owner.steps() + && !steps.contains(&e.id) + { + warning( + out, + e.id, + "unordered_occurrence", + "parent", + "Add this occurrence to its owner's ordered steps.", + ); + } + if let OccurrenceBody::Exchange { + exchange: Some(id), .. + } = body + && let Some(r) = p.relations.get(id) + && (owner.kind() == Kind::Process && r.kind() != RelationKind::Interaction + || owner.kind() == Kind::FunctionalChain + && r.kind() != RelationKind::FunctionalExchange) + { + error( + out, + e.id, + "sequence_exchange_kind", + "exchange", + "OA processes use interactions; SA functional chains use functional exchanges.", + ); + } + } + let check = |out: &mut Vec, line: Option, endpoint: Option, field: &str| { + if let (Some(line), Some(endpoint)) = (line, endpoint) + && lifeline_matches_endpoint(p, line, endpoint) == Some(false) + { + error( + out, + e.id, + "scenario_endpoint", + field, + "The lifeline must represent the endpoint work, its explicitly assigned role, or its compatible participant.", + ); + } + }; + match body { + OccurrenceBody::Exchange { + sender, receiver, .. + } => { + if let Some((source, target)) = message_endpoints(p, body) { + check(out, *sender, Some(source), "sender"); + check(out, *receiver, Some(target), "receiver"); + } + } + OccurrenceBody::Action { action, lifeline } => check(out, *lifeline, *action, "lifeline"), + OccurrenceBody::State { + state: Some(state), + lifeline: Some(line), + } => { + let owner = ancestor_of_kind(p, *state, Kind::StateMachine) + .and_then(|id| p.elements.get(&id)) + .and_then(|e| e.parent); + let represented = match p.elements.get(line).and_then(Element::shared) { + Some(SharedBody::Lifeline { represented }) => *represented, + _ => None, + }; + if let (Some(owner), Some(represented)) = (owner, represented) + && owner != represented + && effective_performer(p, represented) != Some(owner) + { + error( + out, + e.id, + "scenario_state_owner", + "lifeline", + "State observation must agree with the state-machine owner.", + ); + } + } + _ => {} + } +} diff --git a/crates/oa-core/src/persistence.rs b/crates/oa-core/src/persistence.rs index 019ef782..69ec1d8f 100644 --- a/crates/oa-core/src/persistence.rs +++ b/crates/oa-core/src/persistence.rs @@ -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, + pub generated_endpoints: Vec, + pub orientation_defaults: Vec, + pub required_resolutions: Vec, +} +#[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, +} #[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, + }, #[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 { - decode_limited(bytes, crate::language::ParseLimits::UNLIMITED) + decode_with_report(bytes).map(|decoded| decoded.project) +} +pub fn decode_with_report(bytes: &[u8]) -> Result { + 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 { + 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 { 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::>() .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 { + 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 { +) -> Result { + 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 { - decode(&std::fs::read(path)?) + load_with_report(path).map(|decoded| decoded.project) +} +pub fn load_with_report(path: &Path) -> Result { + 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> { diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs index 8e0fc96a..ff0e3104 100644 --- a/crates/oa-core/tests/language.rs +++ b/crates/oa-core/tests/language.rs @@ -252,6 +252,63 @@ project mission "Harbor safety — 港 🌊" { .unwrap() .push(id); } + let owner = p + .elements + .values() + .find(|e| e.alias == "safety_system") + .unwrap() + .id; + let function = p + .elements + .values() + .find(|e| e.alias == "detect") + .unwrap() + .id; + let component_port = Id::new_v4(); + let function_port = Id::new_v4(); + for (id, alias, parent, contract) in [ + ( + component_port, + "typed_component_port", + owner, + PortContract::Component { + kind: ComponentPortKind::Standard, + orientation: ComponentPortOrientation::Unset, + }, + ), + ( + function_port, + "typed_function_output", + function, + PortContract::FunctionOutput { + outgoing_items: vec![], + }, + ), + ] { + p.elements.insert( + id, + Element { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + parent: Some(parent), + body: ElementBody::shared_in( + Layer::Sa, + SharedBody::Port { + contract, + legacy: None, + }, + ), + }, + ); + } + p.relate( + RelationKind::PortAllocation, + "Typed port allocation", + component_port, + function_port, + ); p } @@ -404,7 +461,7 @@ fn authoring_defaults_and_forward_references_remain_strict() { #[test] fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() { for (source, expected) in [ - ("arcadia 3; project mission \"x\" {}", "Unsupported schema"), + ("arcadia 4; project mission \"x\" {}", "Unsupported schema"), ( "arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }", "Unknown field", @@ -708,6 +765,8 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi assert_eq!(from_source, from_json); let original: serde_json::Value = serde_json::from_slice(old_json).unwrap(); let mut retained = serde_json::to_value(&from_json).unwrap(); + assert_eq!(retained["schema_version"], 3); + retained["schema_version"] = 2.into(); for element in retained["elements"].as_object_mut().unwrap().values_mut() { for layer in ["Oa", "Sa"] { let Some(shared) = element["body"] @@ -716,6 +775,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi else { continue; }; + if let Some(port) = shared.get_mut("Port") { + assert_eq!(port["contract"], "Legacy"); + *port = port["legacy"].clone(); + } if let Some(scenario) = shared .get_mut("Scenario") .and_then(serde_json::Value::as_object_mut) @@ -735,6 +798,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi } } for relation in retained["relations"].as_object_mut().unwrap().values_mut() { + if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") { + assert_eq!(boundary["oriented"], true); + boundary.as_object_mut().unwrap().remove("oriented"); + } if let Some(transition) = relation["body"] .get_mut("StateTransition") .and_then(serde_json::Value::as_object_mut) @@ -828,7 +895,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics() assert_eq!(from_text, from_json); assert_eq!(read(text), from_text); assert_eq!(persistence::decode(json).unwrap(), from_json); - assert_eq!(from_text.schema_version, 2); + assert_eq!(from_text.schema_version, 3); assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa)); let raw: serde_json::Value = serde_json::from_slice(json).unwrap(); for (id, record) in raw["elements"].as_object().unwrap() { @@ -1027,7 +1094,9 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() { break; } } - if code.trim_start().starts_with("arcadia 2;") { + if code.trim_start().starts_with("arcadia 2;") + || code.trim_start().starts_with("arcadia 3;") + { read(&code); examples += 1; } diff --git a/crates/oa-core/tests/port_migration.rs b/crates/oa-core/tests/port_migration.rs new file mode 100644 index 00000000..9796224b --- /dev/null +++ b/crates/oa-core/tests/port_migration.rs @@ -0,0 +1,364 @@ +use oa_core::{ + language::{self, ParseLimits}, + persistence::{self, FileError}, + *, +}; +use serde_json::{Value, json}; + +const ORIGINAL: &[u8] = include_bytes!("fixtures/v2-coastal-rescue.json"); +fn original() -> Value { + serde_json::from_slice(ORIGINAL).unwrap() +} +fn exchange_id(old: &Value) -> String { + old["relations"] + .as_object() + .unwrap() + .iter() + .find(|(_, r)| r["body"].get("FunctionalExchange").is_some()) + .unwrap() + .0 + .clone() +} +fn without_endpoints() -> Value { + let mut old = original(); + let id = exchange_id(&old); + old["relations"][&id]["body"]["FunctionalExchange"]["source_port"] = Value::Null; + old["relations"][&id]["body"]["FunctionalExchange"]["target_port"] = Value::Null; + old +} +fn decode(old: &Value) -> persistence::DecodeResult { + persistence::decode_with_report(&serde_json::to_vec(old).unwrap()).unwrap() +} + +#[test] +fn frozen_original_v2_reader_preserves_every_original_record() { + let old = original(); + let decoded = persistence::decode_with_report(ORIGINAL).unwrap(); + let from_source = + language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap(); + assert_eq!(decoded.project, from_source.project); + assert_eq!(decoded.migration, from_source.migration); + let report = decoded.migration.unwrap(); + assert_eq!((report.from_schema, report.to_schema), (2, 3)); + assert_eq!(report.preserved_ports.len(), 4); + assert!(report.generated_endpoints.is_empty()); + assert_eq!(report.orientation_defaults.len(), 1); + assert_eq!( + decoded.project.identities().len(), + old["elements"].as_object().unwrap().len() + + old["relations"].as_object().unwrap().len() + + old["diagrams"] + .as_object() + .unwrap() + .values() + .map(|d| 1 + d["nodes"].as_object().unwrap().len()) + .sum::() + + old["traces"].as_object().unwrap().len() + + old["artifact_links"].as_object().unwrap().len() + + 1 + ); + let current = serde_json::to_value(&decoded.project).unwrap(); + for field in [ + "id", + "alias", + "name", + "description", + "diagrams", + "traces", + "artifact_links", + ] { + assert_eq!(current[field], old[field], "preserve {field}"); + } + for (id, before) in old["elements"].as_object().unwrap() { + let after = ¤t["elements"][id]; + for field in ["id", "alias", "name", "description", "parent"] { + assert_eq!(after[field], before[field], "{id}.{field}"); + } + for layer in ["Oa", "Sa"] { + if let Some(port) = before["body"][layer]["Shared"].get("Port") { + assert_eq!( + after["body"][layer]["Shared"]["Port"], + json!({"contract":"Legacy", "legacy":port}) + ); + } + } + } + for (id, before) in old["relations"].as_object().unwrap() { + let after = ¤t["relations"][id]; + for field in ["id", "alias", "name", "description"] { + assert_eq!(after[field], before[field]); + } + if before["body"].get("StateTransition").is_none() { + let mut expected = before.clone(); + if let Some(body) = expected["body"].get_mut("BoundaryExchange") { + body["oriented"] = true.into(); + } + assert_eq!(*after, expected, "relation {id}"); + } + } +} + +#[test] +fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() { + let old = without_endpoints(); + let first = decode(&old); + let second = decode(&old); + assert_eq!(first.project, second.project); + assert_eq!(first.migration, second.migration); + let report = first.migration.as_ref().unwrap(); + assert_eq!(report.generated_endpoints.len(), 2); + let fe: Id = exchange_id(&old).parse().unwrap(); + let items = first.project.relations[&fe].body.items(); + for endpoint in &report.generated_endpoints { + assert_eq!( + endpoint.port, + Id::new_v5( + &first.project.id, + format!("v2-fe-port:{fe}:{}", endpoint.side).as_bytes() + ) + ); + let port = &first.project.elements[&endpoint.port]; + assert_eq!(port.parent, Some(endpoint.owner)); + assert_eq!(port.alias, endpoint.alias); + match port.shared().unwrap() { + SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + legacy: None, + } => { + assert_eq!(endpoint.side, "source"); + assert_eq!(outgoing_items, items); + } + SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + legacy: None, + } => { + assert_eq!(endpoint.side, "target"); + assert_eq!(incoming_items, items); + } + other => panic!("unexpected migrated endpoint {other:?}"), + } + } + let canonical = language::format(&first.project); + assert!(canonical.starts_with("arcadia 3;")); + let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap(); + assert!(reopened.migration.is_none()); + assert_eq!(reopened.project, first.project); + assert_eq!(canonical, language::format(&reopened.project)); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("migrated.json"); + persistence::save(&path, &first.project).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let loaded = persistence::load_with_report(&path).unwrap(); + assert!(loaded.migration.is_none()); + persistence::save(&path, &loaded.project).unwrap(); + assert_eq!(std::fs::read(path).unwrap(), bytes); +} + +#[test] +fn generated_endpoint_collision_in_any_object_category_is_located_and_rejected() { + let mut old = without_endpoints(); + let fe = exchange_id(&old); + let project: Id = old["id"].as_str().unwrap().parse().unwrap(); + let collision = Id::new_v5(&project, format!("v2-fe-port:{fe}:source").as_bytes()); + old["artifact_links"].as_object_mut().unwrap().insert(collision.to_string(), json!({"id":collision,"alias":"collision_artifact","name":"Keep this identity","description":"Never overwrite me","owner":project,"relation":"References","uri":"docs/evidence.md","revision":null,"fragment":null})); + let bytes = serde_json::to_vec(&old).unwrap(); + let error = persistence::decode(&bytes).unwrap_err().to_string(); + assert!( + error.contains("identity_collision") + && error.contains(&fe) + && error.contains("source_port") + && error.contains(&collision.to_string()), + "{error}" + ); + assert_eq!(serde_json::to_vec(&old).unwrap(), bytes); +} + +#[test] +fn generated_alias_collision_is_deterministically_suffixed() { + let mut old = without_endpoints(); + let first = decode(&old); + let generated = &first.migration.unwrap().generated_endpoints[0]; + let original_port = old["elements"] + .as_object_mut() + .unwrap() + .values_mut() + .find(|e| e["body"]["Sa"]["Shared"].get("Port").is_some()) + .unwrap(); + original_port["alias"] = generated.alias.clone().into(); + let decoded = decode(&old); + let endpoint = &decoded.migration.unwrap().generated_endpoints[0]; + assert_eq!(endpoint.port, generated.port); + assert_ne!(endpoint.alias, generated.alias); + assert!(endpoint.alias.starts_with(&generated.alias)); + assert_eq!(decoded.project, decode(&old).project); +} + +#[test] +fn migration_admission_counts_generated_objects_records_and_references() { + let old = without_endpoints(); + let bytes = serde_json::to_vec(&old).unwrap(); + let graph = + old["elements"].as_object().unwrap().len() + old["relations"].as_object().unwrap().len(); + let migrated = decode(&old).project; + let records = migrated.identities().len(); + let reference_count = migrated + .elements + .values() + .map(|e| e.steps().map_or(0, Vec::len)) + .sum::() + + decode(&old) + .migration + .unwrap() + .generated_endpoints + .iter() + .map(|endpoint| { + migrated.elements[&endpoint.port] + .shared() + .map_or(0, |body| match body { + SharedBody::Port { contract, .. } => contract.items().len() + 2, + _ => 0, + }) + }) + .sum::(); + for limits in [ + ParseLimits { + max_graph_objects: graph + 1, + ..ParseLimits::UNLIMITED + }, + ParseLimits { + max_records: records - 1, + ..ParseLimits::UNLIMITED + }, + ParseLimits { + max_step_references: reference_count - 1, + ..ParseLimits::UNLIMITED + }, + ] { + assert!(matches!( + persistence::decode_with_report_limited(&bytes, limits), + Err(FileError::Limit(_)) + )); + } + let accepted = ParseLimits { + max_graph_objects: graph + 2, + max_records: records, + max_step_references: reference_count, + ..ParseLimits::UNLIMITED + }; + assert!(persistence::decode_with_report_limited(&bytes, accepted).is_ok()); +} + +#[test] +fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() { + let mut old = original(); + let fe = exchange_id(&old); + let port = old["relations"][&fe]["body"]["FunctionalExchange"]["source_port"] + .as_str() + .unwrap() + .to_owned(); + old["elements"][&port]["body"]["Sa"]["Shared"]["Port"]["direction"] = "In".into(); + let error = persistence::decode(&serde_json::to_vec(&old).unwrap()) + .unwrap_err() + .to_string(); + assert!(error.contains("V2") && error.contains(&fe), "{error}"); + let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json"); + let result = persistence::decode_with_report(v1).unwrap(); + assert_eq!(result.project.schema_version, 3); + assert_eq!(result.migration.unwrap().from_schema, 1); + assert_eq!(persistence::decode(v1).unwrap(), result.project); +} + +#[test] +fn loading_v2_does_not_rewrite_original_bytes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("original.json"); + std::fs::write(&path, ORIGINAL).unwrap(); + let loaded = persistence::load_with_report(&path).unwrap(); + assert!(loaded.migration.is_some()); + assert_eq!(std::fs::read(path).unwrap(), ORIGINAL); +} + +#[test] +fn legacy_oa_inout_ports_attachments_and_traces_remain_unclassified() { + let source = r##"arcadia 2; +project old_ports "Existing engineering" id "10000000-0000-0000-0000-000000000001" { + oa_entity observer "Observer" {} + oa_actor operator_one "Operator" {} + oa_port old_oa "OA channel" { parent @observer; direction InOut; } + oa_port peer_oa "OA peer" { parent @operator_one; direction InOut; } + boundary_exchange old_oa_link "Operational communication" { source @old_oa; target @peer_oa; } + oa_activity observe "Observe" {} + oa_activity react "React" {} + interaction operational_message "Operational message" { source @observe; target @react; } + sa_function send "Send" {} + sa_function respond "Respond" {} + sa_exchange_item payload "Payload" {} + sa_interface old_interface "Original association" { items [@payload]; } + sa_port directed "Original directed port" { parent @send; direction Out; interface @old_interface; description "Retain — описание"; } + sa_port bidirectional "Original InOut port" id "10000000-0000-0000-0000-000000000002" { parent @respond; direction InOut; interface @old_interface; } + functional_exchange request_message "Request" { source @send; target @respond; source_port @directed; target_port @bidirectional; items [@payload]; } + functional_exchange reply_message "Reply" id "10000000-0000-0000-0000-000000000003" { source @respond; target @send; source_port @bidirectional; items [@payload]; } + interface_provision legacy_role "Preserved role" { source @bidirectional; target @old_interface; } + sa_constraint port_constraint "Original requirement" { constrained [@bidirectional]; expression "review exact meaning"; } + artifact attachment "Attached rationale" { owner @bidirectional; uri "docs/original.md"; revision "v2"; fragment "#port"; } + trace original_trace "Original correspondence" { oa @old_oa; sa @bidirectional; kind Item; } + diagram sa_view "Existing placement" { layer Sa; } + node original_node "" { diagram @sa_view; element @bidirectional; position { x -0; y 23.5; }; size { width 44; height 22; }; } +} +"##; + // Node declarations intentionally have no display-name string. + let source = source.replace("node original_node \"\"", "node original_node"); + let decoded = language::parse_with_report(&source).unwrap(); + let p = decoded.project; + let report = decoded.migration.unwrap(); + assert_eq!(report.preserved_ports.len(), 4); + assert_eq!(report.generated_endpoints.len(), 1); + assert_eq!(report.orientation_defaults.len(), 1); + let original: Id = "10000000-0000-0000-0000-000000000002".parse().unwrap(); + assert!(matches!( + p.elements[&original].shared(), + Some(SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: Some(_) + }) + }) + )); + assert!( + p.elements + .values() + .filter(|e| e.layer() == Layer::Oa && e.kind() == Kind::Port) + .all(|e| matches!( + e.shared(), + Some(SharedBody::Port { + contract: PortContract::Legacy, + .. + }) + )) + ); + assert_eq!(p.artifact_links.values().next().unwrap().owner, original); + assert_eq!(p.traces.values().next().unwrap().sa, original); + let node = p + .diagrams + .values() + .flat_map(|d| d.nodes.values()) + .next() + .unwrap(); + assert_eq!(node.element, original); + assert_eq!(node.position.x.to_bits(), (-0.0f32).to_bits()); + assert_eq!(node.position.y, 23.5); + assert!( + p.relations + .values() + .any(|r| r.kind() == RelationKind::InterfaceProvision && r.source() == original) + ); + assert_eq!( + p.relations + .values() + .filter(|r| r.kind() == RelationKind::PortAllocation) + .count(), + 0 + ); + assert_eq!(p, language::parse(&language::format(&p)).unwrap()); +} diff --git a/crates/oa-core/tests/port_source.rs b/crates/oa-core/tests/port_source.rs new file mode 100644 index 00000000..d04c938f --- /dev/null +++ b/crates/oa-core/tests/port_source.rs @@ -0,0 +1,109 @@ +use oa_core::{language, persistence, *}; + +const SOURCE: &str = r#"arcadia 3; +project typed_ports "Typed ports" { + sa_system producer "Producer" {} + sa_actor consumer "Consumer" {} + sa_function send "Send" {} + sa_function receive "Receive" {} + allocation sends "Produces" { source @producer; target @send; } + allocation receives "Consumes" { source @consumer; target @receive; } + sa_exchange_item data "Data" {} + sa_interface service "Service" { items [@data]; } + sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; } + sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; } + sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; } + sa_port client "Client port" { parent @consumer; contract { Component { kind Standard; orientation Unset; }; }; } + interface_provision provided "Provides service" { source @provider; target @service; } + interface_requirement required "Requires service" { source @client; target @service; } + functional_exchange message "Message" { source @send; target @receive; source_port @output; target_port @input; items [@data]; } + boundary_exchange connection "Connection" { source @provider; target @client; oriented false; items [@data]; } + port_allocation output_allocation "Output allocation" { source @provider; target @output; } + port_allocation input_allocation "Input allocation" { source @client; target @input; } + exchange_allocation carried "Carried message" { source @connection; target @message; } + sa_port preserved "Preserved generic port" { parent @send; contract Legacy; legacy { direction InOut; interface @service; }; } +} +"#; + +#[test] +fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() { + let decoded = language::parse_with_report(SOURCE).unwrap(); + assert!(decoded.migration.is_none()); + let canonical = language::format(&decoded.project); + for field in [ + "incoming_items [@data]", + "outgoing_items [@data]", + "interface @service", + "source_port @output", + "target_port @input", + "oriented false", + "port_allocation", + ] { + assert!(canonical.contains(field), "{field}\n{canonical}"); + } + assert_eq!(language::parse(&canonical).unwrap(), decoded.project); + assert_eq!( + persistence::decode(&serde_json::to_vec(&decoded.project).unwrap()).unwrap(), + decoded.project + ); + let preserved = decoded + .project + .elements + .values() + .find(|e| e.alias == "preserved") + .unwrap(); + let service = decoded + .project + .elements + .values() + .find(|e| e.alias == "service") + .unwrap() + .id; + assert!(references::element_references(preserved).contains(&service)); + assert!( + language::lint(&canonical) + .iter() + .any(|d| d.object_id == Some(preserved.id) + && d.severity == validation::Severity::Warning) + ); +} + +#[test] +fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() { + for (remove, expected) in [ + ("source_port @output; ", "source_port"), + ("target_port @input; ", "target_port"), + ( + "contract { FunctionOutput { outgoing_items [@data]; }; };", + "contract", + ), + ] { + let candidate = SOURCE.replace(remove, ""); + let errors = language::parse(&candidate).unwrap_err(); + assert!( + errors.iter().any(|d| d.message.contains(expected)), + "{errors:?}" + ); + assert!(errors.iter().all(|d| d.line > 0 && d.column > 0)); + } + assert!(language::parse(&SOURCE.replace("source_port @output", "source_port none")).is_err()); +} + +#[test] +fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() { + let mixed = SOURCE.replace( + "parent @consumer; contract { Component { kind Standard; orientation Unset;", + "parent @consumer; contract { Component { kind Flow; orientation Unset;", + ); + assert!(language::parse(&mixed).is_err()); + let typo = SOURCE.replace("interface @service;", "interface @missing;"); + let error = language::parse(&typo).unwrap_err(); + assert!(error.iter().any(|d| d.message.contains("@missing"))); + let item_typo = SOURCE.replace("incoming_items [@data]", "incoming_items [@missing]"); + assert!( + language::parse(&item_typo) + .unwrap_err() + .iter() + .any(|d| d.message.contains("@missing")) + ); +} diff --git a/crates/oa-server/Cargo.toml b/crates/oa-server/Cargo.toml index d429783c..2da96b18 100644 --- a/crates/oa-server/Cargo.toml +++ b/crates/oa-server/Cargo.toml @@ -15,7 +15,7 @@ axum = { version = "0.8", default-features = false, features = ["http1", "json", tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "io-util"] } tower-http = { version = "0.6", features = ["limit"] } serde.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["raw_value"] } uuid.workspace = true thiserror.workspace = true tempfile = "3" diff --git a/crates/oa-server/src/routes.rs b/crates/oa-server/src/routes.rs index 87e44291..79d7044e 100644 --- a/crates/oa-server/src/routes.rs +++ b/crates/oa-server/src/routes.rs @@ -182,13 +182,13 @@ fn not_modified(stored: &StoredProject, headers: &HeaderMap) -> Option fn summary(stored: &StoredProject) -> Value { json!({"resource_id":stored.resource_id,"revision":stored.revision,"model_id":stored.model.id, "schema_version":stored.model.schema_version,"name":stored.model.name, - "elements":stored.model.elements.len(),"relations":stored.model.relations.len()}) + "elements":stored.model.elements.len(),"relations":stored.model.relations.len(),"migration":stored.migration}) } -fn parse_source(body: &[u8]) -> ApiResult { +fn parse_source(body: &[u8]) -> ApiResult { let source = std::str::from_utf8(body).map_err(|_| ApiError::bad_request("Source must be UTF-8."))?; - oa_core::language::parse_limited(source, MODEL_LIMITS).map_err(|diagnostics| { + oa_core::language::parse_with_report_limited(source, MODEL_LIMITS).map_err(|diagnostics| { let code = if diagnostics.iter().any(|d| d.code == "model_limit") { "model_limit" } else { @@ -215,22 +215,41 @@ fn parse_source(body: &[u8]) -> ApiResult { }) } -fn import_json(body: &[u8]) -> ApiResult { - // Format is chosen by HTTP. The shared decoder owns explicit schema-1 migration. +fn import_json(body: &[u8]) -> ApiResult { + // Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration. let _: Value = json_body(body)?; - oa_core::persistence::decode_limited(body, MODEL_LIMITS).map_err(|error| match error { - oa_core::persistence::FileError::Limit(detail) => { - ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail) - } - oa_core::persistence::FileError::Json(error) => { - ApiError::bad_request(format!("Invalid model JSON: {error}")) - } - error => ApiError::new( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid_model", - error.to_string(), - ), - }) + oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err( + |error| match error { + oa_core::persistence::FileError::Limit(detail) => { + ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail) + } + oa_core::persistence::FileError::Model { message, issues } => { + let mut error = + ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "invalid_model", message); + error.issues = issues + .into_iter() + .map(|issue| IssueDto { + severity: "error", + code: issue.code, + message: issue.message, + field: issue.field, + object_id: issue.element, + line: None, + column: None, + }) + .collect(); + error + } + oa_core::persistence::FileError::Json(error) => { + ApiError::bad_request(format!("Invalid model JSON: {error}")) + } + error => ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_model", + error.to_string(), + ), + }, + ) } #[derive(Deserialize)] @@ -282,7 +301,7 @@ async fn import_project( } else { parse_source(&body)? }; - created(store.create(model)?) + created(store.create_decoded(model)?) }) .await } @@ -477,15 +496,15 @@ async fn replace_model( blocking(state, move |store| { store.checked(id, &expected)?; let model = if is_json { - json_body(&body)? + import_json(&body)? } else { parse_source(&body)? }; - let (stored, changed) = store.replace(id, &expected, model)?; + let (stored, changed) = store.replace_decoded(id, &expected, model)?; Ok(response( &stored, StatusCode::OK, - json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}), + json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}), )) }) .await @@ -822,10 +841,11 @@ async fn get_validation(State(state): State, id: ProjectPath) -> ApiRe } #[derive(Deserialize)] -#[serde(tag = "format", rename_all = "snake_case", deny_unknown_fields)] -enum ValidationRequest { - Json { model: Project }, - Arcadia { source: String }, +#[serde(deny_unknown_fields)] +struct ValidationRequest { + format: String, + model: Option>, + source: Option, } async fn validate_candidate( @@ -841,15 +861,16 @@ async fn validate_candidate( let snapshot = store.get(id)?; let stored = &snapshot; let request: ValidationRequest = json_body(&body)?; - let parsed = match request { - ValidationRequest::Json { model } => Ok(model), - ValidationRequest::Arcadia { source } => parse_source(source.as_bytes()), + let parsed = match (request.format.as_str(), request.model, request.source) { + ("json", Some(model), None) => import_json(model.get().as_bytes()), + ("arcadia", None, Some(source)) => parse_source(source.as_bytes()), + _ => return Err(ApiError::bad_request("Validation requires either format json with only model, or format arcadia with only source.")), }; - let (valid, issues) = match parsed.and_then(|model| { - validate_model(&model)?; - Ok(model) + let (valid, issues, migration) = match parsed.and_then(|decoded| { + validate_model(&decoded.project)?; + Ok(decoded) }) { - Ok(model) => (true, issues(&model)), + Ok(decoded) => (true, issues(&decoded.project), decoded.migration), Err(error) => { let mut diagnostics = error.issues; if diagnostics.is_empty() { @@ -863,13 +884,13 @@ async fn validate_candidate( column: None, }); } - (false, diagnostics) + (false, diagnostics, None) } }; Ok(response( stored, StatusCode::OK, - json!({"revision":stored.revision,"valid":valid,"issues":issues}), + json!({"revision":stored.revision,"valid":valid,"issues":issues,"migration":migration}), )) }) .await diff --git a/crates/oa-server/src/store.rs b/crates/oa-server/src/store.rs index b91216c9..252413c3 100644 --- a/crates/oa-server/src/store.rs +++ b/crates/oa-server/src/store.rs @@ -1,6 +1,7 @@ //! Single-owner on-disk envelopes. All methods run on a blocking worker. use crate::error::{ApiError, ApiResult, issues}; use axum::http::StatusCode; +use oa_core::persistence::{DecodeResult, MigrationReport}; use oa_core::{Id, Project}; use serde::{Deserialize, Serialize}; use std::{ @@ -29,6 +30,8 @@ pub struct StoredProject { pub resource_id: Id, pub revision: u64, pub model: Project, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub migration: Option, } impl StoredProject { @@ -46,6 +49,7 @@ pub(crate) struct ProjectSummary { pub name: String, pub elements: usize, pub relations: usize, + pub migration: Option, } impl From<&StoredProject> for ProjectSummary { fn from(stored: &StoredProject) -> Self { @@ -57,6 +61,7 @@ impl From<&StoredProject> for ProjectSummary { name: stored.model.name.clone(), elements: stored.model.elements.len(), relations: stored.model.relations.len(), + migration: stored.migration.clone(), } } } @@ -88,11 +93,11 @@ struct StoreInner { /// Constructed only after complete candidate validation, outside the catalog /// mutex. Commit methods cannot accidentally accept an unchecked candidate. -struct PreparedModel(Project); +struct PreparedModel(Project, Option); impl PreparedModel { fn new(model: Project) -> ApiResult { validate_model(&model)?; - Ok(Self(model)) + Ok(Self(model, None)) } } @@ -129,6 +134,22 @@ impl Store { self.lock()?.create(prepared) } + pub(crate) fn create_decoded(&self, decoded: DecodeResult) -> ApiResult { + let mut prepared = PreparedModel::new(decoded.project)?; + prepared.1 = decoded.migration; + self.lock()?.create(prepared) + } + pub(crate) fn replace_decoded( + &self, + id: Id, + expected: &str, + decoded: DecodeResult, + ) -> ApiResult<(StoredProject, bool)> { + let mut prepared = PreparedModel::new(decoded.project)?; + prepared.1 = decoded.migration; + self.lock()?.replace(id, expected, prepared) + } + pub(crate) fn replace( &self, id: Id, @@ -201,8 +222,26 @@ impl Store { "At most 128 models and 256 MiB of serialized envelopes are supported.".into(), )); } - let stored: StoredProject = + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Envelope { + resource_id: Id, + revision: u64, + model: Box, + } + let envelope: Envelope = serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?; + let decoded = oa_core::persistence::decode_with_report_limited( + envelope.model.get().as_bytes(), + MODEL_LIMITS, + ) + .map_err(|error| corrupt(error.to_string()))?; + let stored = StoredProject { + resource_id: envelope.resource_id, + revision: envelope.revision, + model: decoded.project, + migration: decoded.migration, + }; if stored.resource_id != filename_id || stored.revision == 0 { return Err(corrupt( "Resource ID/filename mismatch or zero revision.".into(), @@ -254,6 +293,7 @@ impl StoreInner { resource_id: Id::new_v4(), revision: 1, model, + migration: prepared.1, }; let size = self.write(&stored, true)?; self.adopt(stored.clone(), size); @@ -270,7 +310,11 @@ impl StoreInner { let current = self.checked(id, expected)?; let model = prepared.0; if current.model == model { - return Ok((current.clone(), false)); + let mut unchanged = current.clone(); + if prepared.1.is_some() { + unchanged.migration = prepared.1; + } + return Ok((unchanged, false)); } let revision = current.revision.checked_add(1).ok_or_else(|| { ApiError::new( @@ -283,6 +327,7 @@ impl StoreInner { resource_id: id, revision, model, + migration: prepared.1, }; let size = self.write(&stored, false)?; self.adopt(stored.clone(), size); @@ -312,7 +357,9 @@ impl StoreInner { } fn write(&self, stored: &StoredProject, create: bool) -> ApiResult { - let bytes = serde_json::to_vec(stored) + // Migration observations are response metadata, never persisted facts. + let envelope = serde_json::json!({"resource_id": stored.resource_id, "revision": stored.revision, "model": stored.model}); + let bytes = serde_json::to_vec(&envelope) .map_err(|_| ApiError::internal("Could not serialize model."))?; if bytes.len() > MAX_ENVELOPE_BYTES { return Err(ApiError::new( diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs index 23d1fc5f..15af6b5d 100644 --- a/crates/oa-server/tests/http.rs +++ b/crates/oa-server/tests/http.rs @@ -8,6 +8,20 @@ use serde_json::{Value, json}; use std::{path::Path, sync::Arc}; use tokio::{sync::oneshot, task::JoinHandle}; +fn v2_missing_ports() -> Value { + let mut old: Value = serde_json::from_slice(include_bytes!( + "../../oa-core/tests/fixtures/v2-coastal-rescue.json" + )) + .unwrap(); + for relation in old["relations"].as_object_mut().unwrap().values_mut() { + if let Some(exchange) = relation["body"].get_mut("FunctionalExchange") { + exchange["source_port"] = Value::Null; + exchange["target_port"] = Value::Null; + } + } + old +} + struct TestServer { base: String, client: Client, @@ -1318,6 +1332,7 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() { std::fs::write(&path, "{broken").unwrap(); assert!(Store::open(directory.path()).is_err()); let mismatched = StoredProject { + migration: None, resource_id: Id::new_v4(), revision: 1, model: Project::empty(), @@ -1325,6 +1340,7 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() { std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap(); assert!(Store::open(directory.path()).is_err()); let invalid = StoredProject { + migration: None, resource_id: filename, revision: 0, model: Project::empty(), @@ -1484,6 +1500,7 @@ async fn fixed_store_capacity_is_enforced_on_http_and_restart() { let mut last = None; for _ in 0..oa_server::store::MAX_PROJECTS { let envelope = StoredProject { + migration: None, resource_id: Id::new_v4(), revision: 1, model: Project::empty(), @@ -1624,3 +1641,154 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address( ); server.stop().await; } + +#[tokio::test] +async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_state() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let old = v2_missing_ports(); + let expected = + oa_core::persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap(); + let imported = server + .client + .post(server.url("/projects/import")) + .json(&old) + .send() + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::CREATED); + let etag = imported.headers()["etag"].to_str().unwrap().to_owned(); + let summary: Value = imported.json().await.unwrap(); + assert_eq!(summary["schema_version"], 3); + assert_eq!( + summary["migration"], + serde_json::to_value(&expected.migration).unwrap() + ); + let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap(); + let stored = server.get(id).await; + assert_eq!(stored.model, expected.project); + assert_eq!(stored.migration, expected.migration); + let filename = directory.path().join(format!("{id}.json")); + let before = std::fs::read(&filename).unwrap(); + let envelope: Value = serde_json::from_slice(&before).unwrap(); + assert!(envelope.get("migration").is_none()); + assert_eq!(envelope["model"]["schema_version"], 3); + let validation = server + .client + .post(server.url(&format!("/projects/{id}/validation"))) + .json(&json!({"format":"json", "model":old})) + .send() + .await + .unwrap(); + assert_eq!(validation.status(), StatusCode::OK); + let validation: Value = validation.json().await.unwrap(); + assert_eq!(validation["valid"], true); + assert_eq!(validation["migration"], summary["migration"]); + let mut invalid = stored + .model + .relations + .values() + .find(|r| r.kind() == RelationKind::FunctionalExchange) + .unwrap() + .clone(); + if let oa_core::RelationBody::FunctionalExchange { source_port, .. } = &mut invalid.body { + *source_port = Id::nil(); + } + let rejected = server + .client + .post(server.url(&format!("/projects/{id}/transactions"))) + .header("if-match", &etag) + .json(&json!({"commands":[Command::EditRelation(invalid.id, invalid)]})) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(server.get(id).await.revision, 1); + assert_eq!(std::fs::read(&filename).unwrap(), before); + let source = server + .client + .get(server.url(&format!("/projects/{id}/source"))) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(source.starts_with("arcadia 3;")); + assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model); + let no_op = server + .client + .put(server.url(&format!("/projects/{id}/source"))) + .header("content-type", "text/plain") + .header("if-match", &etag) + .body(source) + .send() + .await + .unwrap(); + assert_eq!(no_op.status(), StatusCode::OK); + assert_eq!(no_op.headers()["etag"].to_str().unwrap(), etag); + assert_eq!(std::fs::read(&filename).unwrap(), before); + server.stop().await; + let restarted = TestServer::start(directory.path(), None).await; + let reopened = restarted.get(id).await; + assert_eq!(reopened.model, stored.model); + assert_eq!(reopened.revision, 1); + assert!(reopened.migration.is_none()); + let no_op_legacy = restarted + .client + .put(restarted.url(&format!("/projects/{id}/model"))) + .header("if-match", &etag) + .json(&old) + .send() + .await + .unwrap(); + assert_eq!(no_op_legacy.status(), StatusCode::OK); + assert_eq!(no_op_legacy.headers()["etag"].to_str().unwrap(), etag); + let no_op_legacy: Value = no_op_legacy.json().await.unwrap(); + assert_eq!(no_op_legacy["changed"], false); + assert_eq!(no_op_legacy["migration"], summary["migration"]); + assert_eq!(std::fs::read(&filename).unwrap(), before); + restarted.stop().await; +} + +#[tokio::test] +async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_revision() { + let directory = tempfile::tempdir().unwrap(); + let id = Id::new_v4(); + let filename = directory.path().join(format!("{id}.json")); + let old = v2_missing_ports(); + let original = serde_json::to_vec(&json!({"resource_id":id,"revision":7,"model":old})).unwrap(); + std::fs::write(&filename, &original).unwrap(); + let server = TestServer::start(directory.path(), None).await; + let migrated = server.get(id).await; + assert_eq!(migrated.resource_id, id); + assert_eq!(migrated.revision, 7); + assert_eq!(migrated.model.schema_version, 3); + assert_eq!( + migrated + .migration + .as_ref() + .unwrap() + .generated_endpoints + .len(), + 2 + ); + assert_eq!(std::fs::read(&filename).unwrap(), original); + let rename = server + .client + .patch(server.url(&format!("/projects/{id}"))) + .header("if-match", migrated.etag()) + .json(&json!({"name":"Reviewed migrated model"})) + .send() + .await + .unwrap(); + assert_eq!(rename.status(), StatusCode::OK); + let current = server.get(id).await; + assert_eq!(current.revision, 8); + assert_eq!(current.model.name, "Reviewed migrated model"); + let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap(); + assert_eq!(saved["revision"], 8); + assert_eq!(saved["model"]["schema_version"], 3); + assert!(saved.get("migration").is_none()); + server.stop().await; +} diff --git a/docs/language.rst b/docs/language.rst index 400a05a7..391bf38d 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -1,7 +1,7 @@ Arcadia Studio OA / SA Language and Editing Contract ==================================================== -:Version: 2 +:Version: 3 :Status: Normative current implementation contract; first-release integration :Core implementation: ``crates/oa-core`` :Text suffix: ``.arcadia`` @@ -11,17 +11,15 @@ Arcadia Studio OA / SA Language and Editing Contract This specification defines the shared Operational Analysis (OA) and System Analysis (SA) model, its textual language, graphical editing obligations, -validation, persistence, and explicit migration from version 1. MUST, MUST NOT, +validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT, and MAY indicate requirements, prohibitions, and permitted choices. -The first-release capability/mission, contextual process/chain, typed scenario -and state/event additions -are temporarily integrated under schema 2. This does not declare the first -release complete or waive its remaining scenario, state, data, transition and -usability requirements. A distinct release schema and explicit V2 migration -will be specified after those interfaces are frozen. This document describes -the current implemented records, rather than inventing compatibility rules for -that future schema. +Schema 3 introduces typed function and component ports, required functional +exchange endpoints, independently oriented boundary exchanges, and explicit +PortAllocation relations. First-release scenario/state additions remain in +scope; this schema change does not declare native acceptance or the wider +release complete. The preserved schema-2 reader validates the original +contract before migration. OA describes stakeholders, operational work and needs. SA describes the selected system boundary, external actors, functions, services and expected @@ -42,7 +40,7 @@ Actual verification evidence is separate from this contract. 2. One authoritative model -------------------------- -A Project stores ``schema_version`` (2), ``id``, ``alias``, ``name``, +A Project stores ``schema_version`` (3), ``id``, ``alias``, ``name``, ``description``, and maps named ``elements``, ``relations``, ``diagrams``, ``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in its record. Duplicate map keys and mismatching keys/identities are errors. @@ -108,8 +106,8 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts - ``items``: exchange-item refs - ``[]`` * - ``oa_port``, ``sa_port`` - - ``direction``: In, Out, InOut; ``interface``: ref? - - InOut; none + - ``contract``: required PortContract; ``legacy``: optional LegacyPortContract + - No implicit contract; ``legacy`` defaults none. OA admits Legacy only. * - ``oa_scenario``, ``sa_scenario`` - ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs - MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]`` @@ -205,7 +203,7 @@ contain activities; functions contain functions. Capability grouping uses same-layer capability parents; it does not mean inheritance or inclusion. Operational and system missions are root semantic records; they do not own capabilities by containment. MissionCapability expresses that association. -DataFields belong to ExchangeItems or Record DataTypes. Ports belong to +DataFields belong to ExchangeItems or Record DataTypes. Preserved generic ports belong to participants or work. Lifelines belong to scenarios. State-machine ownership uses participant → machine → region → state/mode; a state or mode can contain a nested region. All other state vertices belong to Regions as well. Events @@ -235,10 +233,13 @@ references. Their kind is the declaration keyword: - ``items`` refs / [] * - ``functional_exchange`` - SA function → SA function - - ``source_port``, ``target_port`` / none; ``items`` / [] + - Required ``source_port``, ``target_port`` refs; ``items`` / [] * - ``boundary_exchange`` - - Participant-owned output-compatible port → input-compatible port - - ``items`` refs / [] + - Different participant owners; typed component ports have matching kinds + - ``oriented`` bool / true; ``items`` refs / [] + * - ``port_allocation`` + - SA Component port → SA FunctionInput/FunctionOutput port + - Requires the function's actual work Allocation to that component * - ``capability_involvement`` - Participant, activity/function, or OA role → capability - None @@ -297,12 +298,75 @@ imply a unique performer. Allocation of a non-leaf work element is invalid. A computed graphical summary of its allocated leaves MUST NOT create an extra semantic allocation. -Functional ports, when selected, belong to the corresponding endpoint function -and have compatible direction. Boundary ports belong to participants and have -compatible direction. An interface groups ExchangeItems; an exchange using -that port interface must carry items supported by it. Studio's OA boundary -exchange is a simplified representation of operational communication; it is -not a claim of complete Capella CommunicationMean equivalence. +Typed function ports belong directly to SA Functions. ``FunctionOutput`` +declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``. +The lists contain distinct same-layer ExchangeItem references. A functional +exchange MUST reference output/input ports owned by its respective functions, +and each carried item MUST fit both declarations. Empty declarations accept +no items. Item compatibility is identity equality; future specialization is +not inferred from names. Port declarations remain independent of incident +exchanges after creation. + +A ``Component`` contract belongs to an SA System or SystemActor and stores +``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut). +Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed +component connections require matching kinds: Standard/Flow mismatch is an +error. Standard connections normally use ``oriented false``; directional Flow +connections use true. Boundary orientation is separate from endpoint kind and +orientation. Oriented Flow endpoints permit Out at source and In at target; +unoriented Flow endpoints must permit at least one communication direction. + +Provided/required interfaces are separate identified relations. A Standard +partner satisfies a requirement by providing the identical interface; stored +source/target order does not infer service roles. Component carried items must +fit the declared interface-item union when explicit declarations exist. An +explicitly empty interface is not a wildcard. Missing declarations and +unsatisfied required interfaces produce located completeness warnings. + +``PortAllocation`` relates a Component port to a typed function port only when +that function has an actual Allocation to the component owner. Standard may +allocate either function direction; Flow directions must agree. A typed +ExchangeAllocation requires both corresponding PortAllocations and compatible +carried items. Unoriented boundary exchanges allow reversed correspondence. +Visual allocation nesting is never evidence of a stored allocation relation. + +``Legacy`` requires ``legacy { direction ...; interface ...; }``. It preserves +generic direction/interface data and emits a located compatibility warning. +It is an explicit compatibility constructor in JSON/source; ordinary creation +pickers do not offer it. Resolution to a typed SA contract retains this record +without treating it as another active constraint. OA generic ports remain +Legacy; OA Interactions stay activity-to-activity without function ports. +Deleting a referenced legacy interface clears that optional live reference. + +A source-3 functional exchange MUST specify both port aliases; the parser does +not create endpoints. Graphical/command FE creation plans ports atomically, +using unique compatible typed-port reuse or deterministic new endpoints. +Port deletion cascades to incident FEs; FE deletion retains independent ports. + +For example:: + + arcadia 3; + project ports_example "Typed function contract" { + sa_function send "Send" {} + sa_function receive "Receive" {} + sa_exchange_item payload "Payload" {} + sa_interface old_service "Preserved service" { items [@payload]; } + sa_port output "Output" { + parent @send; + contract { FunctionOutput { outgoing_items [@payload]; }; }; + } + sa_port input "Input" { + parent @receive; + contract { FunctionInput { incoming_items [@payload]; }; }; + } + functional_exchange message "Message" { + source @send; target @receive; + source_port @output; target_port @input; items [@payload]; + } + sa_port preserved "Unresolved original" { + parent @send; contract Legacy; + legacy { direction InOut; interface @old_service; }; + } An exclusive region has at most one initial state. Missing initial state is a completeness warning. Initial states have no incoming transitions and at @@ -814,16 +878,16 @@ exchanges it references. Cross-layer counterparts are not owned descendants. Text edits describe a complete candidate and must remove or repair dependencies explicitly before Apply succeeds. -9. Persistence and V1 migration -------------------------------- +9. Persistence and explicit migration +------------------------------------- Loading detects JSON from its first non-whitespace ``{``; other input is parsed as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses -canonical V2 text; other suffixes use V2 JSON. Both forms preserve the same +canonical V3 text (``arcadia 3``); other suffixes use V3 JSON. Both forms preserve the same complete model. JSON rejects unknown fields throughout typed payloads, duplicate maps, unsupported versions, and structural errors. -During first-release schema-2 integration, old Scenario bodies without a +The frozen schema-2 reader retains staged compatibility defaults. Old Scenario bodies without a ``viewpoint`` retain MixedLegacy, and old Exchange occurrence bodies without ``message_kind``, ``reply_to`` or ``values`` retain Asynchronous, none and []. Old StateTransition bodies without ``transition_kind``, ``triggers`` or @@ -836,8 +900,33 @@ defaults without changing any prior identity, name, reference, order, geometry, artifact or trace field. The original V2 golden source/JSON in ``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo; they verify that every old persistent field survives. Canonical output writes -the added defaults. A future schema-3 adapter remains a separate required -migration and is not implied by this temporary compatibility behavior. +the added defaults through the explicit schema-3 migration. + +The V2 adapter preserves every original identity, alias, prose, parent, +placement, trace and artifact. Generic ports become Legacy with their original +direction/interface record. Existing FE endpoint IDs remain unchanged. For +only a missing endpoint it creates an item-bearing FunctionOutput/Input owned +by the already specified function, using UUID v5 in the project namespace +with ``v2-fe-port::source`` or ``v2-fe-port::target``. Every +identity category is checked for collision; collisions are located errors, +never overwrites. New aliases follow the deterministic collision-free alias +convention. Every old boundary exchange receives ``oriented true``; no +PortAllocation is inferred. + +``decode_with_report``, ``load_with_report`` and their limited/source variants +return the model plus a migration report containing original unresolved port +IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary +orientation defaults. REST import, replacement, candidate validation and +project snapshots surface the same report. Opening an old server envelope +preserves its resource ID/revision and bytes; the next explicit edit writes +schema 3. Model/source exports contain only the canonical model. Migration +reports are transient response metadata, not persisted model facts. + +Admission checks account for generated objects and their references before +construction. Both the old V2 contract and the resulting V3 graph are +validated. Migration itself does not save or increase a stored revision. +Canonical second saves are stable. V1 normalization produces the frozen V2 +model and then uses this same V2-to-3 stage. Save validates a snapshot, writes a sibling temporary file, flushes it, and atomically replaces the destination. Failed serialization or validation must @@ -856,7 +945,7 @@ step becomes a distinct occurrence element owned by its process. Generated diagram, node, and occurrence identities use UUID v5 with the old Project ID as namespace and deterministic role/index names. If such an identity collides, a deterministic suffix is tried. Reimporting the same fully identified V1 -input therefore yields the same V2 result, including repeated occurrences. +input therefore yields the same V3 result, including repeated occurrences. No System or SA content is invented during an OA-only migration. V1 source aliases are retained when valid in V2. JSON imports generate the old @@ -864,7 +953,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed by a deterministic suffix; aliases were not persistent V1 semantic data. A project alias and aliases for new records are generated without colliding with retained aliases. The existing bundled V1 text and JSON migrate to equal -V2 projects. Future versions remain errors until a separate migration exists. +V3 projects. Future versions remain errors until a separate migration exists. 10. Complete authoring example ------------------------------ @@ -874,7 +963,7 @@ the assigned UUIDs explicitly. .. code-block:: text - arcadia 2; + arcadia 3; project harbor "Harbor assistance" { oa_entity control "Harbor control" {} oa_activity assess "Assess assistance" {} diff --git a/docs/openapi.json b/docs/openapi.json index 37745c15..b0386eee 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Arcadia Studio model API", "version": "1.0.0", - "description": "Current schema-2 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag." + "description": "Current schema-3 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag." }, "servers": [ { @@ -389,7 +389,7 @@ } } }, - "description": "Schema-2 native JSON is described below. Schema-1 JSON is also accepted via the core's explicit migration adapter; see docs/language.rst for the legacy contract. Export emits schema 2." + "description": "Schema-3 native JSON is described below. Schema-1 and schema-2 JSON/source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Reports list preserved Legacy contracts, generated FE endpoint IDs/aliases and boundary orientation defaults. Export emits schema 3 / arcadia 3. See docs/language.rst." } }, "/api/v1/projects/{id}": { @@ -1711,7 +1711,8 @@ "CapabilityExtend", "CapabilityGeneralization", "MissionInvolvement", - "SequenceLink" + "SequenceLink", + "PortAllocation" ] }, "Position": { @@ -2269,19 +2270,13 @@ "Port": { "type": "object", "properties": { - "direction": { - "type": "string", - "enum": [ - "In", - "Out", - "InOut" - ] + "contract": { + "$ref": "#/components/schemas/PortContract" }, - "interface": { + "legacy": { "anyOf": [ { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/LegacyPortContract" }, { "type": "null" @@ -2290,8 +2285,8 @@ } }, "required": [ - "direction", - "interface" + "contract", + "legacy" ], "additionalProperties": false } @@ -3005,12 +3000,17 @@ "type": "string", "format": "uuid" } + }, + "oriented": { + "type": "boolean", + "description": "Independent of component port kind/orientation. Old V2 exchanges migrate to true." } }, "required": [ "source", "target", - "items" + "items", + "oriented" ], "additionalProperties": false } @@ -3035,26 +3035,12 @@ "format": "uuid" }, "source_port": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + "type": "string", + "format": "uuid" }, "target_port": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + "type": "string", + "format": "uuid" }, "items": { "type": "array", @@ -3322,6 +3308,33 @@ "SequenceLink" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "PortAllocation": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "PortAllocation" + ], + "additionalProperties": false } ] }, @@ -3616,7 +3629,7 @@ "properties": { "schema_version": { "type": "integer", - "const": 2 + "const": 3 }, "id": { "type": "string", @@ -3707,6 +3720,17 @@ }, "model": { "$ref": "#/components/schemas/Project" + }, + "migration": { + "anyOf": [ + { + "$ref": "#/components/schemas/MigrationReport" + }, + { + "type": "null" + } + ], + "description": "Present after legacy import/reopen; excluded from persisted envelopes. Resource identity and revision are unchanged by reopening." } }, "required": [ @@ -3745,6 +3769,16 @@ "relations": { "type": "integer", "minimum": 0 + }, + "migration": { + "anyOf": [ + { + "$ref": "#/components/schemas/MigrationReport" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -4618,6 +4652,16 @@ "items": { "$ref": "#/components/schemas/Issue" } + }, + "migration": { + "anyOf": [ + { + "$ref": "#/components/schemas/MigrationReport" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -4678,6 +4722,16 @@ "items": { "$ref": "#/components/schemas/Issue" } + }, + "migration": { + "anyOf": [ + { + "$ref": "#/components/schemas/MigrationReport" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -5438,6 +5492,247 @@ "additionalProperties": false } ] + }, + "PortContract": { + "oneOf": [ + { + "type": "object", + "properties": { + "FunctionInput": { + "type": "object", + "properties": { + "incoming_items": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "incoming_items" + ], + "additionalProperties": false + } + }, + "required": [ + "FunctionInput" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "FunctionOutput": { + "type": "object", + "properties": { + "outgoing_items": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "outgoing_items" + ], + "additionalProperties": false + } + }, + "required": [ + "FunctionOutput" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Component": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "Standard", + "Flow" + ] + }, + "orientation": { + "type": "string", + "enum": [ + "Unset", + "In", + "Out", + "InOut" + ] + } + }, + "required": [ + "kind", + "orientation" + ], + "additionalProperties": false + } + }, + "required": [ + "Component" + ], + "additionalProperties": false + }, + { + "type": "string", + "enum": [ + "Legacy" + ] + } + ], + "description": "FunctionInput/Output are SA Function-owned item contracts. Component is SA System/SystemActor-owned; Standard requires Unset. Legacy requires a preserved legacy record and emits compatibility warnings. Mixed Standard/Flow component connections are invalid." + }, + "LegacyPortContract": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": [ + "In", + "Out", + "InOut" + ] + }, + "interface": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "direction", + "interface" + ], + "additionalProperties": false + }, + "MigrationReport": { + "type": "object", + "properties": { + "from_schema": { + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "to_schema": { + "type": "integer", + "const": 3 + }, + "preserved_ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string" + }, + "required_resolution": { + "type": "string" + } + }, + "required": [ + "id", + "alias", + "required_resolution" + ], + "additionalProperties": false + } + }, + "generated_endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "exchange": { + "type": "string", + "format": "uuid" + }, + "side": { + "type": "string", + "enum": [ + "source", + "target" + ] + }, + "port": { + "type": "string", + "format": "uuid" + }, + "owner": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string" + } + }, + "required": [ + "exchange", + "side", + "port", + "owner", + "alias" + ], + "additionalProperties": false + } + }, + "orientation_defaults": { + "type": "array", + "items": { + "type": "object", + "properties": { + "exchange": { + "type": "string", + "format": "uuid" + }, + "oriented": { + "type": "boolean", + "const": true + } + }, + "required": [ + "exchange", + "oriented" + ], + "additionalProperties": false + } + }, + "required_resolutions": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "from_schema", + "to_schema", + "preserved_ports", + "generated_endpoints", + "orientation_defaults", + "required_resolutions" + ], + "additionalProperties": false, + "description": "Transient observations from explicit V1→V2→V3 or V2→V3 migration. No save or revision increase occurs merely by reading an old envelope." } } } diff --git a/harness/release-port-migration.md b/harness/release-port-migration.md new file mode 100644 index 00000000..eb4263e6 --- /dev/null +++ b/harness/release-port-migration.md @@ -0,0 +1,37 @@ +# Typed port migration, source and API handoff + +Implemented in `codex/typed-port-contracts`, isolated worktree +`D:/CodexWorktrees/Capella2-typed-ports`, against the frozen port contract. + +- Private V2 DTO, source reader and validation snapshot captured from `c20a207`. + V1 normalizes to that DTO, then follows the same V2-to-3 adapter. +- Existing port identity/prose/ownership/placement and interface associations + survive as Legacy. Missing FE endpoints receive deterministic item-bearing + typed ports; old boundary exchanges explicitly retain `oriented = true`. +- Generated identities are collision checked across all object categories; + aliases use the existing deterministic convention. Original admission limits + remain intact, with generated records and references charged before creation. +- `persistence::{decode_with_report, decode_with_report_limited, load_with_report}` + and `language::{parse_with_report, parse_with_report_limited}` return + `DecodeResult { project, migration }`. Existing Project-only wrappers remain. + Desktop integration should use the report-returning calls to display the + migration findings. Merely loading never writes or advances a revision. +- Source 3 requires a port contract and both explicit FE port aliases. Item + declarations and nested `legacy.interface` use normal source references. + Canonical output is schema 3 / `arcadia 3`; source 1/2 remain importable. +- REST import, replacement, candidate validation and project snapshots surface + reports. Reopened V2 envelopes retain resource/revision/bytes until a real edit. + Reports are transient response metadata, excluded from persisted envelopes. + Raw model JSON is preserved through request/envelope parsing so duplicate-map + detection cannot be bypassed. Invalid typed transactions retain disk and ETag. +- Language/OpenAPI documents include typed contracts, PortAllocation, explicit + endpoint IDs, independent orientation, Legacy and strict Standard/Flow kind + mismatch rejection. Original V2 goldens and worked-example files are untouched. + +Validation: 16 language, 8 migration, 3 typed source and 4 existing admission +tests pass. The HTTP suite has 19 passing tests, including migration/restart and +rejected typed transaction coverage. Core+server Clippy with all targets and +`-D warnings` passes. Server unit/doc checks are included in final verification. + +Native GUI acceptance and report presentation remain root integration work; +this handoff makes no native-UI or broad R3 completion claim.