Migrate schema 2 ports through frozen readers and expose schema 3 source API
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-09-05 18:23:33 +03:00
parent 0d14438b7b
commit b9f88ce1f3
21 changed files with 7513 additions and 152 deletions
+70 -19
View File
@@ -1,4 +1,6 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1 migration.
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fmt};
@@ -317,6 +319,8 @@ fn reference_field(key: &str) -> bool {
| "branches"
| "Named"
| "items"
| "incoming_items"
| "outgoing_items"
| "triggers"
| "effects"
| "interface"
@@ -351,6 +355,8 @@ fn enum_field(key: &str) -> bool {
key,
"kind"
| "direction"
| "orientation"
| "contract"
| "layer"
| "projection"
| "relation"
@@ -364,13 +370,24 @@ fn enum_field(key: &str) -> bool {
)
}
/// Parse V2, or explicitly migrate a valid V1 source candidate. Parsing never
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
/// adopts a model and never overwrites an editor draft.
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
parse_limited(source, ParseLimits::UNLIMITED)
}
pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<Diagnostic>> {
parse_with_report_limited(source, limits).map(|decoded| decoded.project)
}
pub fn parse_with_report(
source: &str,
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
parse_with_report_limited(source, ParseLimits::UNLIMITED)
}
pub fn parse_with_report_limited(
source: &str,
limits: ParseLimits,
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
let parsed = parse_document(source, limits)?;
let errors: Vec<_> = parsed
.diagnostics(source)
@@ -378,7 +395,10 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
.filter(|d| d.severity == validation::Severity::Error)
.collect();
if errors.is_empty() {
Ok(parsed.project)
Ok(crate::persistence::DecodeResult {
project: parsed.project,
migration: parsed.migration,
})
} else {
Err(errors)
}
@@ -386,6 +406,7 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
struct ParsedDocument {
project: Project,
migration: Option<crate::persistence::MigrationReport>,
locations: BTreeMap<Id, usize>,
fields: BTreeMap<(Id, String), usize>,
}
@@ -460,22 +481,31 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
})
.collect::<Vec<_>>()
})?;
let project = crate::persistence::migrate_v1_limited(old, limits).map_err(|message| {
vec![diagnostic(
source,
Error {
code: "invalid_source",
offset: 0,
message,
},
)]
})?;
let decoded =
crate::persistence::migrate_v1_with_report_limited(old, limits).map_err(|error| {
vec![diagnostic(
source,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"invalid_source"
},
offset: 0,
message: error.to_string(),
},
)]
})?;
return Ok(ParsedDocument {
project,
project: decoded.project,
migration: decoded.migration,
locations: BTreeMap::new(),
fields: BTreeMap::new(),
});
}
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
return v2_source::parse(source, tokens, limits);
}
Parser {
source,
tokens,
@@ -510,7 +540,7 @@ pub fn format(project: &Project) -> String {
.map(|(id, a)| (id, a.to_owned()))
.collect::<BTreeMap<_, _>>();
let mut out = std::format!(
"arcadia 2;\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -1000,8 +1030,8 @@ impl Parser<'_> {
}
fn document(&mut self) -> Result<ParsedDocument, Error> {
self.take("arcadia")?;
if self.text() != "2" {
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
if self.text() != "3" {
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
}
self.cursor += 1;
self.take(";")?;
@@ -1116,7 +1146,22 @@ impl Parser<'_> {
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
let mut initial = ElementBody::new(kind, layer).unwrap();
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
"Missing required field 'contract'.".into(),
));
}
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: None,
},
)
} else {
ElementBody::new(kind, layer).unwrap()
};
// Schema-2 sources predate explicit scenario viewpoints.
// Preserve their mixed lifelines instead of reinterpreting
// old source using a new graphical factory's typed default.
@@ -1151,7 +1196,12 @@ impl Parser<'_> {
DeclKind::Relation(kind) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
for required in ["source", "target"] {
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
&["source", "target", "source_port", "target_port"]
} else {
&["source", "target"]
};
for &required in required {
if !fields.contains_key(required) {
return Err(conversion_error(std::format!(
"Missing required field '{required}'."
@@ -1246,6 +1296,7 @@ impl Parser<'_> {
})?;
Ok(ParsedDocument {
project,
migration: None,
locations,
fields: field_locations,
})
+21
View File
@@ -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.
+214
View File
@@ -0,0 +1,214 @@
use crate::language::ParseLimits;
use crate::persistence::{
DecodeResult, FileError, GeneratedEndpoint, MigrationReport, OrientationDefault, PreservedPort,
};
use crate::*;
use serde_json::Value;
use std::collections::BTreeSet;
/// Validate the real V2 graph, then explicitly transform its changed payloads.
/// The JSON bridge below copies unchanged DTO fields; it never reads old bytes
/// as a current Project and never lets current constructors supply old defaults.
pub(crate) fn migrate(
old: super::model::Project,
limits: ParseLimits,
) -> Result<DecodeResult, FileError> {
super::check_limits(&old, limits).map_err(FileError::Limit)?;
let mut generated = 0usize;
let mut additional_references = 0usize;
for r in old.relations.values() {
if let super::model::RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} = &r.body
{
let missing = usize::from(source_port.is_none()) + usize::from(target_port.is_none());
generated = generated.saturating_add(missing);
additional_references = additional_references
.saturating_add(missing.saturating_mul(items.len().saturating_add(2)));
}
}
let (graph, records, references) = super::counts(&old);
limits
.check_counts(
graph.saturating_add(generated),
records.saturating_add(generated),
references.saturating_add(additional_references),
)
.map_err(FileError::Limit)?;
if let Some(issue) = super::validation::validate(&old)
.into_iter()
.find(|i| i.severity == super::validation::Severity::Error)
{
let message = format!(
"V2 {} at {}.{}: {}",
issue.code,
issue
.element
.map_or_else(|| "project".into(), |id| id.to_string()),
issue.field.as_deref().unwrap_or_default(),
issue.message
);
return Err(FileError::Model {
message,
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: issue.element,
code: issue.code,
field: issue.field,
message: issue.message,
}],
});
}
let mut report = MigrationReport {
from_schema: 2,
to_schema: 3,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
};
let mut value = serde_json::to_value(&old)?;
value["schema_version"] = Value::from(SCHEMA_VERSION);
for element in old.elements.values() {
if let Some(super::model::SharedBody::Port {
direction,
interface,
}) = element.shared()
{
let layer = match element.layer() {
super::model::Layer::Oa => "Oa",
super::model::Layer::Sa => "Sa",
};
value["elements"][element.id.to_string()]["body"][layer]["Shared"]["Port"] = serde_json::json!({"contract":"Legacy", "legacy":{"direction":direction,"interface":interface}});
let required_resolution = if element.layer() == super::model::Layer::Oa {
"Keep this OA Legacy contract unresolved or review an explicit SA counterpart; typed ports are SA-only."
} else {
"Review the preserved generic direction/interface contract before selecting a typed SA port contract."
};
report.preserved_ports.push(PreservedPort {
id: element.id,
alias: element.alias.clone(),
required_resolution: required_resolution.into(),
});
report.required_resolutions.push(element.id);
}
}
let mut used_ids: BTreeSet<_> = old.identities().into_iter().map(|(id, _)| id).collect();
// Use the existing alias convention against the old graph plus each newly
// planned endpoint, so cross-category alias collisions are deterministic.
let mut alias_project = old.clone();
for relation in old.relations.values() {
match &relation.body {
super::model::RelationBody::FunctionalExchange {
source,
target,
source_port,
target_port,
items,
} => {
for (side, owner, existing, output) in [
("source", *source, source_port, true),
("target", *target, target_port, false),
] {
if existing.is_some() {
continue;
}
let id = Id::new_v5(
&old.id,
format!("v2-fe-port:{}:{side}", relation.id).as_bytes(),
);
if !used_ids.insert(id) {
let message = format!(
"V2 migration identity_collision at {}.{}_port: deterministic endpoint {} already belongs to another object.",
relation.id, side, id
);
return Err(FileError::Model {
message: message.clone(),
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: Some(relation.id),
code: "identity_collision".into(),
field: Some(format!("{side}_port")),
message,
}],
});
}
let role = if output { "output" } else { "input" };
let name = if relation.name.trim().is_empty() {
format!("Function {role}")
} else {
format!("{} {role}", relation.name)
};
let alias = alias_project.fresh_alias(&name, "port", id);
// Only identity/name/alias enter this planning-only V2 map.
alias_project.elements.insert(
id,
super::model::Element {
id,
alias: alias.clone(),
name: name.clone(),
description: String::new(),
parent: Some(owner),
body: super::model::ElementBody::Sa(super::model::SaBody::Function),
},
);
let contract = if output {
PortContract::FunctionOutput {
outgoing_items: items.clone(),
}
} else {
PortContract::FunctionInput {
incoming_items: items.clone(),
}
};
let element = Element {
id,
alias: alias.clone(),
name,
description: String::new(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract,
legacy: None,
},
),
};
value["elements"]
.as_object_mut()
.expect("V2 elements map")
.insert(id.to_string(), serde_json::to_value(element)?);
value["relations"][relation.id.to_string()]["body"]["FunctionalExchange"]
[format!("{side}_port")] = serde_json::to_value(id)?;
report.generated_endpoints.push(GeneratedEndpoint {
exchange: relation.id,
side: side.into(),
port: id,
owner,
alias,
});
}
}
super::model::RelationBody::BoundaryExchange { .. } => {
value["relations"][relation.id.to_string()]["body"]["BoundaryExchange"]["oriented"] =
Value::Bool(true);
report.orientation_defaults.push(OrientationDefault {
exchange: relation.id,
oriented: true,
});
}
_ => {}
}
}
let project: Project = serde_json::from_value(value)?;
limits.check_model(&project).map_err(FileError::Limit)?;
super::super::validate_project(&project)?;
Ok(DecodeResult {
project,
migration: Some(report),
})
}
+33
View File
@@ -0,0 +1,33 @@
//! Version-2 DTO and validation snapshot. Kept separate from the writable model
//! so accepting an old file cannot accidentally inherit version-3 defaults.
#![allow(dead_code)]
pub(crate) mod adapter;
pub(crate) mod model;
mod references;
mod state_validation;
mod transitions;
pub(crate) mod validation;
pub(crate) fn counts(project: &model::Project) -> (usize, usize, usize) {
let graph = project
.elements
.len()
.saturating_add(project.relations.len());
let records = graph.saturating_add(project.identities().len().saturating_sub(graph));
// Keep the established ordered-reference budget for original records.
// The adapter separately charges every reference it would generate.
let references = project
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.fold(0usize, usize::saturating_add);
(graph, records, references)
}
pub(crate) fn check_limits(
project: &model::Project,
limits: crate::language::ParseLimits,
) -> Result<(), String> {
let (graph, records, references) = counts(project);
limits.check_counts(graph, records, references)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
use super::model::*;
use std::collections::BTreeSet;
/// Every live reference in a typed element, including structural ownership.
pub fn element_references(e: &Element) -> Vec<Id> {
let mut copy = e.clone();
let mut refs = Vec::new();
visit_element_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn relation_references(r: &Relation) -> Vec<Id> {
let mut copy = r.clone();
let mut refs = Vec::new();
visit_relation_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
if let Some(parent) = &mut e.parent {
visit(parent);
}
match &mut e.body {
ElementBody::Oa(OaBody::Process { steps })
| ElementBody::Sa(SaBody::FunctionalChain { steps }) => {
for id in steps {
visit(id);
}
}
_ => {}
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::DataField {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { items } => {
for id in items {
visit(id);
}
}
SharedBody::Event {
body: EventBody::Signal { items },
} => {
for id in items {
visit(id);
}
}
SharedBody::Port {
interface: Some(id),
..
} => visit(id),
SharedBody::Scenario { context, steps, .. } => {
if let Some(id) = context {
visit(id);
}
for id in steps {
visit(id);
}
}
SharedBody::Lifeline {
represented: Some(id),
} => visit(id),
SharedBody::Occurrence { body, .. } => match body {
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
reply_to,
values,
..
} => {
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
visit(id);
}
for value in values {
visit(&mut value.field);
}
}
OccurrenceBody::Action { action, lifeline } => {
for id in [action, lifeline].into_iter().flatten() {
visit(id);
}
}
OccurrenceBody::SequenceReference { sequence } => {
if let Some(id) = sequence {
visit(id);
}
}
OccurrenceBody::Control { .. } => {}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
if let Some(id) = scenario {
visit(id);
}
for binding in bindings {
visit(&mut binding.referenced);
visit(&mut binding.local);
}
}
OccurrenceBody::State { state, lifeline } => {
for id in [state, lifeline].into_iter().flatten() {
visit(id);
}
}
OccurrenceBody::Fragment { branches, .. } => {
for id in branches {
visit(id);
}
}
OccurrenceBody::Branch { occurrences, .. } => {
for id in occurrences {
visit(id);
}
}
},
SharedBody::Constraint { constrained, .. } => {
for id in constrained {
visit(id);
}
}
_ => {}
}
}
}
pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
let (mut source, mut target) = r.body.endpoints();
visit(&mut source);
visit(&mut target);
r.body.set_endpoints(source, target);
match &mut r.body {
RelationBody::StateTransition {
triggers, effects, ..
} => {
for id in triggers.iter_mut().chain(effects) {
visit(id);
}
}
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
for id in items {
visit(id);
}
}
RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} => {
for id in [source_port, target_port].into_iter().flatten() {
visit(id);
}
for id in items {
visit(id);
}
}
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
*id = new;
}
});
}
for r in p.relations.values_mut() {
visit_relation_refs(r, |id| {
if *id == old {
*id = new;
}
});
}
for d in p.diagrams.values_mut() {
for n in d.nodes.values_mut() {
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
a.owner = new;
}
}
for t in p.traces.values_mut() {
if t.oa == old {
t.oa = new;
}
if t.sa == old {
t.sa = new;
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
let mut removed = BTreeSet::from([id]);
loop {
let before = removed.len();
for e in p.elements.values() {
let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t));
if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type {
removed.insert(e.id);
}
if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r))
{
removed.insert(e.id);
}
}
for r in p.relations.values() {
if removed.contains(&r.source()) || removed.contains(&r.target()) {
removed.insert(r.id);
}
}
for d in p.diagrams.values() {
for n in d.nodes.values() {
if removed.contains(&d.id) || removed.contains(&n.element) {
removed.insert(n.id);
}
}
}
for t in p.traces.values() {
if removed.contains(&t.oa) || removed.contains(&t.sa) {
removed.insert(t.id);
}
}
for a in p.artifact_links.values() {
if removed.contains(&a.owner) {
removed.insert(a.id);
}
}
if removed.len() == before {
break;
}
}
removed
}
pub fn delete_cascade(p: &mut Project, id: Id) {
let removed = deletion_impact(p, id);
let removed_names: std::collections::BTreeMap<_, _> = removed
.iter()
.map(|id| (*id, p.named(*id).to_string()))
.collect();
p.elements.retain(|id, _| !removed.contains(id));
p.relations.retain(|id, _| !removed.contains(id));
p.diagrams.retain(|id, _| !removed.contains(id));
p.traces.retain(|id, _| !removed.contains(id));
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.nodes.retain(|id, _| !removed.contains(id));
}
let clear = |v: &mut Option<Id>| {
if v.is_some_and(|id| removed.contains(&id)) {
*v = None;
}
};
for e in p.elements.values_mut() {
if let Some(steps) = e.steps_mut() {
steps.retain(|id| !removed.contains(id));
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
SharedBody::Port { interface, .. } => clear(interface),
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
constrained.retain(|id| !removed.contains(id))
}
SharedBody::Occurrence { body, .. } => match body {
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
reply_to,
values,
..
} => {
clear(exchange);
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| !removed.contains(&value.field));
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
clear(lifeline);
}
OccurrenceBody::SequenceReference { sequence } => clear(sequence),
OccurrenceBody::Control { .. } => {}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
clear(scenario);
bindings.retain(|binding| {
!removed.contains(&binding.referenced)
&& !removed.contains(&binding.local)
});
}
OccurrenceBody::State { state, lifeline } => {
clear(state);
clear(lifeline);
}
OccurrenceBody::Fragment { branches, .. } => {
branches.retain(|id| !removed.contains(id))
}
OccurrenceBody::Branch { .. } => {}
},
_ => {}
}
}
}
for r in p.relations.values_mut() {
match &mut r.body {
RelationBody::StateTransition {
triggers,
effects,
trigger,
..
} => {
let deleted: Vec<_> = triggers
.iter()
.filter(|id| removed.contains(id))
.filter_map(|id| removed_names.get(id).cloned())
.collect();
triggers.retain(|id| !removed.contains(id));
effects.retain(|id| !removed.contains(id));
if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() {
*trigger = format!(
"Removed trigger: {}. Choose a replacement trigger.",
deleted.join(", ")
);
}
}
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
items.retain(|id| !removed.contains(id));
clear(source_port);
clear(target_port);
}
_ => {}
}
}
// Removing an item or a containing payload field can disconnect a still-live
// record field from an exchange. Its contextual annotation is no longer valid.
let allowed: std::collections::BTreeMap<_, _> = p
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id), ..
},
..
}) => p
.relations
.get(id)
.map(|r| (e.id, super::validation::carried_fields(p, r))),
_ => None,
})
.collect();
for (id, fields) in allowed {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{
values.retain(|value| fields.contains(&value.field));
}
}
}
+546
View File
@@ -0,0 +1,546 @@
//! Frozen schema-2 declaration reader from c20a207, with explicit migration and located diagnostics.
use super::{
Ast, Error, Lexeme, ParseLimits, TokenClass, merge_fields, payload, replace_payload,
take_optional_id, take_required_id, take_string,
};
use crate::persistence::v2::model::*;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy)]
enum DeclKind {
Element(Kind, Layer),
Relation(RelationKind),
Diagram,
Node,
Artifact,
Trace,
}
fn snake(value: &str) -> String {
let mut out = String::new();
for (i, c) in value.chars().enumerate() {
if c.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
} else {
out.push(c)
}
}
out
}
fn element_word(kind: Kind, layer: Layer) -> String {
let stem = match kind {
Kind::SystemActor => "actor".into(),
Kind::SystemCapability => "capability".into(),
Kind::OperationalMission => "mission".into(),
_ => snake(serde_json::to_value(kind).unwrap().as_str().unwrap()),
};
std::format!("{}_{stem}", if layer == Layer::Oa { "oa" } else { "sa" })
}
fn relation_word(kind: RelationKind) -> String {
snake(serde_json::to_value(kind).unwrap().as_str().unwrap())
}
fn declaration_kind(word: &str) -> Option<DeclKind> {
static KINDS: std::sync::OnceLock<BTreeMap<String, DeclKind>> = std::sync::OnceLock::new();
KINDS
.get_or_init(|| {
let mut kinds = BTreeMap::from([
("diagram".into(), DeclKind::Diagram),
("node".into(), DeclKind::Node),
("artifact".into(), DeclKind::Artifact),
("trace".into(), DeclKind::Trace),
]);
for layer in [Layer::Oa, Layer::Sa] {
for kind in Kind::ALL {
if kind.supports(layer) {
kinds.insert(element_word(kind, layer), DeclKind::Element(kind, layer));
}
}
}
for kind in RelationKind::ALL {
kinds.insert(relation_word(kind), DeclKind::Relation(kind));
}
kinds
})
.get(word)
.copied()
}
struct Declaration {
kind: DeclKind,
alias: String,
name: String,
id: Id,
fields: BTreeMap<String, Ast>,
offset: usize,
}
struct Parser2<'a> {
source: &'a str,
tokens: Vec<Lexeme>,
cursor: usize,
value_depth: usize,
limits: ParseLimits,
semantic_error: Option<crate::validation::Issue>,
}
impl Parser2<'_> {
fn current(&self) -> Lexeme {
self.tokens[self.cursor.min(self.tokens.len() - 1)]
}
fn text(&self) -> &str {
self.current().text(self.source)
}
fn error(&self, message: impl Into<String>) -> Error {
Error {
code: "invalid_source",
offset: self.current().span.start,
message: message.into(),
}
}
fn take(&mut self, word: &str) -> Result<(), Error> {
if self.text() != word {
return Err(self.error(std::format!("Expected '{word}'.")));
}
self.cursor += 1;
Ok(())
}
fn quoted(&mut self) -> Result<String, Error> {
if self.current().class != TokenClass::String {
return Err(self.error("Expected a double-quoted string."));
}
let value = serde_json::from_str(self.text()).unwrap();
self.cursor += 1;
Ok(value)
}
fn identifier(&mut self) -> Result<String, Error> {
let value = self.text().to_owned();
if !valid_alias(&value) {
return Err(self.error(std::format!("Invalid or reserved alias '{value}'.")));
}
self.cursor += 1;
Ok(value)
}
fn optional_id(&mut self) -> Result<Id, Error> {
if self.text() != "id" {
return Ok(Id::new_v4());
}
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
return Err(Error {
code: "invalid_source",
offset,
message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(),
});
}
Id::parse_str(&value).map_err(|_| Error {
code: "invalid_source",
offset,
message: "Invalid UUID identity.".into(),
})
}
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
self.take("{")?;
let mut fields = BTreeMap::new();
while self.text() != "}" {
let key = self.text().to_owned();
let offset = self.current().span.start;
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let value = self.value()?;
self.take(";")?;
if fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: std::format!("Duplicate field '{key}'."),
});
}
}
self.take("}")?;
Ok(fields)
}
fn value(&mut self) -> Result<Ast, Error> {
if self.value_depth >= 64 {
return Err(self.error("Typed values may not nest more than 64 levels."));
}
self.value_depth += 1;
let result = self.value_inner();
self.value_depth -= 1;
result
}
fn value_inner(&mut self) -> Result<Ast, Error> {
let token = self.current();
let offset = token.span.start;
match self.text() {
"@" => {
self.cursor += 1;
Ok(Ast::Reference(self.identifier()?, offset))
}
"{" => Ok(Ast::Object(self.fields()?, offset)),
"[" => {
self.cursor += 1;
let mut values = Vec::new();
if self.text() != "]" {
loop {
values.push(self.value()?);
if self.text() != "," {
break;
}
self.cursor += 1;
}
}
self.take("]")?;
Ok(Ast::Array(values, offset))
}
"none" => {
self.cursor += 1;
Ok(Ast::Literal(Value::Null, offset))
}
"true" | "false" => {
let value = self.text() == "true";
self.cursor += 1;
Ok(Ast::Literal(Value::Bool(value), offset))
}
_ => match token.class {
TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)),
TokenClass::Number => {
let value = serde_json::from_str(self.text())
.map_err(|_| self.error("Expected a finite JSON decimal number."))?;
self.cursor += 1;
Ok(Ast::Literal(value, offset))
}
TokenClass::Identifier | TokenClass::Keyword => {
let value = self.text().to_owned();
self.cursor += 1;
Ok(Ast::Word(value, offset))
}
_ => Err(self.error("Expected a value, @alias reference, list, or typed record.")),
},
}
}
fn document(&mut self) -> Result<super::ParsedDocument, Error> {
self.take("arcadia")?;
if self.text() != "2" {
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
}
self.cursor += 1;
self.take(";")?;
self.take("project")?;
let alias = self.identifier()?;
let name = self.quoted()?;
let id = self.optional_id()?;
self.take("{")?;
let mut project = Project {
schema_version: SCHEMA_VERSION,
id,
alias: alias.clone(),
name,
description: String::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
};
let mut aliases = BTreeMap::from([(alias, id)]);
let mut locations = BTreeMap::from([(id, 0)]);
let mut declarations = Vec::new();
let mut graph_count = 0usize;
let mut field_locations = BTreeMap::new();
let mut has_description = false;
while self.text() != "}" {
if self.text() == "description" {
if has_description {
return Err(self.error("Duplicate project description."));
}
has_description = true;
self.cursor += 1;
project.description = self.quoted()?;
self.take(";")?;
continue;
}
let offset = self.current().span.start;
let word = self.text().to_owned();
let kind = declaration_kind(&word)
.ok_or_else(|| self.error(std::format!("Unknown declaration '{word}'.")))?;
graph_count += usize::from(matches!(
kind,
DeclKind::Element(..) | DeclKind::Relation(..)
));
self.limits
.check_counts(graph_count, declarations.len().saturating_add(2), 0)
.map_err(|message| Error {
code: "model_limit",
offset: self.current().span.start,
message,
})?;
self.cursor += 1;
let alias = self.identifier()?;
let name = if matches!(kind, DeclKind::Node) {
String::new()
} else {
self.quoted()?
};
let id = self.optional_id()?;
let fields = self.fields()?;
for (field, value) in &fields {
field_locations.insert((id, field.clone()), value.offset());
}
if aliases.insert(alias.clone(), id).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: std::format!("Duplicate alias '{alias}'."),
});
}
if locations.insert(id, offset).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: std::format!("Duplicate identity '{id}'."),
});
}
declarations.push(Declaration {
kind,
alias,
name,
id,
fields,
offset,
});
}
self.take("}")?;
if !self.text().is_empty() {
return Err(self.error("Unexpected content after the project closing brace."));
}
let mut nodes = Vec::new();
for declaration in declarations {
let mut fields = declaration
.fields
.iter()
.map(|(k, v)| Ok((k.clone(), v.resolve(k, &aliases)?)))
.collect::<Result<Map<_, _>, Error>>()?;
let d = &declaration;
let conversion_error = |message: String| Error {
code: "invalid_source",
offset: d
.fields
.iter()
.find(|(field, _)| message.contains(&std::format!("'{field}'")))
.map_or(d.offset, |(_, value)| value.offset()),
message,
};
match d.kind {
DeclKind::Element(kind, layer) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
let mut initial = ElementBody::new(kind, layer).unwrap();
// Schema-2 sources predate explicit scenario viewpoints.
// Preserve their mixed lifelines instead of reinterpreting
// old source using a new graphical factory's typed default.
if !fields.contains_key("viewpoint")
&& let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut()
{
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let body_fields =
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
replace_payload(&mut body, body_fields);
let body: ElementBody = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
if body.kind() != kind || body.layer() != layer {
return Err(conversion_error(
"Declaration kind disagrees with its typed body.".into(),
));
}
project.elements.insert(
d.id,
Element {
id: d.id,
alias: d.alias.clone(),
name: d.name.clone(),
description,
parent,
body,
},
);
}
DeclKind::Relation(kind) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
for required in ["source", "target"] {
if !fields.contains_key(required) {
return Err(conversion_error(std::format!(
"Missing required field '{required}'."
)));
}
}
let mut body =
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
.unwrap();
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
replace_payload(&mut body, merged);
let body = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
project.relations.insert(
d.id,
Relation {
id: d.id,
alias: d.alias.clone(),
name: d.name.clone(),
description,
body,
},
);
}
DeclKind::Diagram => {
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview"});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
record.insert("nodes".into(), serde_json::json!({}));
let diagram: Diagram = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.diagrams.insert(d.id, diagram);
}
DeclKind::Node => {
let diagram =
take_required_id(&mut fields, "diagram").map_err(&conversion_error)?;
if !fields.contains_key("element") {
return Err(conversion_error("Missing required field 'element'.".into()));
}
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
let node: NodePlacement = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
nodes.push((diagram, node, d.offset));
}
DeclKind::Artifact => {
if !fields.contains_key("owner") {
return Err(conversion_error("Missing required field 'owner'.".into()));
}
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let artifact = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.artifact_links.insert(d.id, artifact);
}
DeclKind::Trace => {
for required in ["oa", "sa"] {
if !fields.contains_key(required) {
return Err(conversion_error(std::format!(
"Missing required field '{required}'."
)));
}
}
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.traces.insert(d.id, trace);
}
}
}
for (diagram, node, offset) in nodes {
let target = project.diagrams.get_mut(&diagram).ok_or_else(|| Error {
code: "invalid_source",
offset,
message: "Node 'diagram' must reference a diagram declaration.".into(),
})?;
target.nodes.insert(node.id, node);
}
crate::persistence::v2::check_limits(&project, self.limits).map_err(|message| Error {
code: "model_limit",
offset: 0,
message,
})?;
let decoded =
crate::persistence::migrate_v2_limited(project, self.limits).map_err(|error| {
let offset = match &error {
crate::persistence::FileError::Model { issues, .. } => {
self.semantic_error = issues.first().cloned();
issues
.first()
.and_then(|issue| {
issue.element.and_then(|id| {
issue
.field
.as_ref()
.and_then(|field| field_locations.get(&(id, field.clone())))
.or_else(|| locations.get(&id))
})
})
.copied()
.unwrap_or(0)
}
_ => 0,
};
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"invalid_source"
},
offset,
message: error.to_string(),
}
})?;
Ok(super::ParsedDocument {
project: decoded.project,
migration: decoded.migration,
locations,
fields: field_locations,
})
}
}
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
fields.insert("id".into(), Value::String(d.id.to_string()));
fields.insert("alias".into(), Value::String(d.alias.clone()));
if named {
fields.insert("name".into(), Value::String(d.name.clone()));
}
}
pub(super) fn parse(
source: &str,
tokens: Vec<Lexeme>,
limits: ParseLimits,
) -> Result<super::ParsedDocument, Vec<super::Diagnostic>> {
let mut parser = Parser2 {
source,
tokens,
cursor: 0,
value_depth: 0,
limits,
semantic_error: None,
};
parser.document().map_err(|error| {
let mut diagnostic = super::diagnostic(source, error);
if let Some(issue) = parser.semantic_error {
diagnostic.code = issue.code;
diagnostic.object_id = issue.element;
diagnostic.field = issue.field;
}
vec![diagnostic]
})
}
@@ -0,0 +1,470 @@
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
//! Structural state-machine rules. Expressions describe intent; they are not executed.
use super::{
model::*,
validation::{Issue, ancestor_of_kind, error, warning},
};
use std::collections::BTreeSet;
fn ancestors(p: &Project, id: Id) -> Vec<Id> {
let mut result = Vec::new();
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut seen = BTreeSet::from([id]);
while let Some(id) = current {
if !seen.insert(id) {
break;
}
result.push(id);
current = p.elements.get(&id).and_then(|e| e.parent);
}
result
}
fn within(p: &Project, id: Id, owner: Id) -> bool {
ancestors(p, id).contains(&owner)
}
fn boundary(p: &Project, vertex: &Element) -> Option<Id> {
vertex
.parent
.and_then(|region| p.elements.get(&region))
.and_then(|region| region.parent)
}
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
ancestors(p, vertex).into_iter().find(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner))
})
}
fn edges<'a>(
p: &'a Project,
candidate: Option<&'a Relation>,
) -> impl Iterator<Item = &'a Relation> {
p.relations
.values()
.filter(move |r| candidate.is_none_or(|c| c.id != r.id))
.chain(candidate)
.filter(|r| r.kind() == RelationKind::StateTransition)
}
fn guard(r: &Relation) -> &str {
match &r.body {
RelationBody::StateTransition { guard, .. } => guard.trim(),
_ => "",
}
}
fn orthogonal(p: &Project, arms: &[Id]) -> bool {
let Some(first) = arms.first() else {
return true;
};
if arms.len() < 2 {
return true;
}
let Some(owner) = ancestors(p, *first).into_iter().find(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine)
&& arms.iter().all(|arm| within(p, *arm, *id))
}) else {
return false;
};
let regions: Option<BTreeSet<_>> = arms
.iter()
.map(|arm| first_region(p, *arm, owner))
.collect();
regions.is_some_and(|regions| regions.len() == arms.len())
}
pub(crate) fn validate_transition(
p: &Project,
r: &Relation,
a: &Element,
b: &Element,
out: &mut Vec<Issue>,
) {
let RelationBody::StateTransition {
transition_kind,
trigger,
triggers,
guard,
..
} = &r.body
else {
return;
};
let machine = ancestor_of_kind(p, a.id, Kind::StateMachine);
if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) {
error(
out,
r.id,
"transition_machine",
"endpoints",
"State transition endpoints belong to one state machine.",
);
}
if !a.kind().is_state() || !b.kind().is_state() {
return;
}
if a.id == b.id && !a.kind().is_stable_state() {
error(
out,
r.id,
"pseudostate_self",
"endpoints",
"Only a State or Mode can have a self-transition.",
);
}
let valid_kind = match transition_kind {
TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id,
TransitionKind::Local if a.kind() == Kind::EntryPoint => {
boundary(p, a).is_some_and(|owner| within(p, b.id, owner))
}
TransitionKind::Local => {
a.kind().is_stable_state()
&& p.children(a.id).any(|e| e.kind() == Kind::Region)
&& within(p, b.id, a.id)
}
TransitionKind::External => a.kind() != Kind::EntryPoint,
};
if !valid_kind {
error(
out,
r.id,
"transition_kind",
"transition_kind",
"Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.",
);
}
if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial {
error(
out,
r.id,
"pseudostate_direction",
"endpoints",
"Final/Terminate have no outgoing transitions; Initial has no incoming transition.",
);
}
let triggered = !trigger.trim().is_empty() || !triggers.is_empty();
if !a.kind().is_stable_state() && triggered {
error(
out,
r.id,
if a.kind() == Kind::Initial {
"initial_transition"
} else {
"pseudostate_trigger"
},
"triggers",
"Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.",
);
}
if (matches!(
a.kind(),
Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory
) || b.kind() == Kind::Join)
&& (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered))
{
error(
out,
r.id,
if a.kind() == Kind::Initial {
"initial_transition"
} else {
"pseudostate_segment"
},
"guard",
"Initial/history fallback and Fork output/Join input segments have no guard or trigger.",
);
}
if a.kind() == Kind::Fork && !b.kind().is_stable_state()
|| b.kind() == Kind::Join && !a.kind().is_stable_state()
{
error(
out,
r.id,
"orthogonal_endpoint",
"endpoints",
"Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.",
);
}
if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) {
let scope = a.parent.is_some_and(|region| {
if a.kind() == Kind::ShallowHistory {
b.parent == Some(region)
} else {
within(p, b.id, region)
}
});
if !b.kind().is_stable_state() || !scope {
error(
out,
r.id,
"history_scope",
"target",
"Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.",
);
}
}
for (point, other, outgoing) in [(a, b, true), (b, a, false)] {
if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) {
continue;
}
let Some(owner) = boundary(p, point) else {
continue;
};
let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing;
if within(p, other.id, owner) != needs_inside || other.id == owner {
error(
out,
r.id,
"boundary_transition",
"endpoints",
"EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.",
);
}
}
}
pub(crate) fn validate_vertex(
p: &Project,
e: &Element,
candidate: Option<&Relation>,
out: &mut Vec<Issue>,
) {
if !e.kind().is_state() {
return;
}
let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect();
let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect();
let (ins, outs) = (incoming.len(), outgoing.len());
let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1);
let max_out = matches!(
e.kind(),
Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint
)
.then_some(1);
if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) {
error(
out,
e.id,
if e.kind() == Kind::Initial {
"initial_transition_count"
} else {
"pseudostate_cardinality"
},
"body",
format!(
"{} has too many {} transitions.",
e.kind(),
if max_in.is_some_and(|max| ins > max) {
"incoming"
} else {
"outgoing"
}
),
);
}
let incomplete = match e.kind() {
Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0,
Kind::Final | Kind::Terminate => ins == 0,
Kind::Choice | Kind::Fork => ins == 0 || outs < 2,
Kind::Join => ins < 2 || outs == 0,
Kind::Junction => ins == 0 || outs == 0,
Kind::EntryPoint => outs == 0,
Kind::ExitPoint => {
ins == 0
|| (outs == 0
&& boundary(p, e)
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| e.kind() != Kind::StateMachine))
}
_ => false,
};
if incomplete {
warning(
out,
e.id,
"incomplete_state_vertex",
"body",
format!(
"Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.",
e.kind()
),
);
}
if e.kind() == Kind::Choice {
let defaults = outgoing.iter().filter(|r| guard(r) == "else").count();
if defaults > 1 {
error(
out,
e.id,
"choice_default",
"guard",
"Choice has at most one literal else branch.",
);
}
if defaults == 0 {
warning(
out,
e.id,
"missing_choice_default",
"guard",
"Add an else branch, or review and document why no alternative may apply.",
);
}
if outgoing.iter().any(|r| guard(r).is_empty()) {
warning(
out,
e.id,
"missing_choice_guard",
"guard",
"Each Choice alternative needs an explicit guard or literal else.",
);
}
}
if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) {
warning(
out,
e.id,
"junction_guard",
"guard",
"Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.",
);
}
if matches!(e.kind(), Kind::Fork | Kind::Join) {
let arms: Vec<_> = if e.kind() == Kind::Fork {
outgoing.iter().map(|r| r.target()).collect()
} else {
incoming.iter().map(|r| r.source()).collect()
};
if !orthogonal(p, &arms) {
error(
out,
e.id,
"orthogonal_regions",
"endpoints",
"Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.",
);
}
}
if e.kind() == Kind::EntryPoint
&& let Some(owner) = boundary(p, e)
{
let regions: Vec<_> = outgoing
.iter()
.filter_map(|r| first_region(p, r.target(), owner))
.collect();
let distinct: BTreeSet<_> = regions.iter().copied().collect();
if distinct.len() != regions.len() {
error(
out,
e.id,
"entry_point_cardinality",
"target",
"EntryPoint has at most one outgoing transition per destination region.",
);
}
if p.children(owner)
.filter(|r| r.kind() == Kind::Region)
.any(|r| {
!distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial)
})
{
warning(
out,
e.id,
"entry_region_default",
"target",
"Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.",
);
}
}
}
pub(crate) fn validate_graph(p: &Project, out: &mut Vec<Issue>) {
for e in p.elements.values() {
validate_vertex(p, e, None, out);
if e.kind() == Kind::Region {
for (kind, code) in [
(Kind::Initial, "multiple_initial"),
(Kind::ShallowHistory, "multiple_shallow_history"),
(Kind::DeepHistory, "multiple_deep_history"),
] {
if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 {
error(
out,
e.id,
code,
"parent",
format!("A region has at most one {kind}."),
);
}
}
let explicit_entry =
edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id));
if !p
.children(e.id)
.any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint))
&& !explicit_entry
{
warning(
out,
e.id,
"missing_initial",
"parent",
"Add an Initial default entry or an explicit incoming entry path for this region.",
);
}
}
if e.kind() == Kind::StateMachine {
validate_reachability(p, e.id, out);
}
}
}
fn validate_reachability(p: &Project, machine: Id, out: &mut Vec<Issue>) {
let mut pending: Vec<_> = p
.children(machine)
.filter(|e| e.kind() == Kind::Region)
.flat_map(|region| p.children(region.id))
.filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint))
.map(|e| e.id)
.collect();
let mut reached = BTreeSet::new();
while let Some(id) = pending.pop() {
if !reached.insert(id) {
continue;
}
pending.extend(
edges(p, None)
.filter(|r| r.source() == id)
.map(Relation::target),
);
// Entering a nested vertex activates its composite ancestors. Reaching a
// composite also permits each region's default entry; this deliberately
// over-approximates guards/history instead of pretending to execute them.
pending.extend(ancestors(p, id).into_iter().filter(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_stable_state())
}));
pending.extend(
p.children(id)
.filter(|e| e.kind() == Kind::Region)
.flat_map(|region| p.children(region.id))
.filter(|e| e.kind() == Kind::Initial)
.map(|e| e.id),
);
}
for e in p.elements.values().filter(|e| {
e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
}) {
if !reached.contains(&e.id) {
warning(
out,
e.id,
"unreachable_state",
"body",
"No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).",
);
}
}
}
@@ -0,0 +1,24 @@
use super::model::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Drift {
Unreviewed,
Unchanged,
OaChanged,
SaChanged,
BothChanged,
}
pub fn trace_drift(p: &Project, t: &Trace) -> Drift {
let Some(base) = &t.baseline else {
return Drift::Unreviewed;
};
let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else {
return Drift::Unreviewed;
};
match (oa != base.oa, sa != base.sa) {
(false, false) => Drift::Unchanged,
(true, false) => Drift::OaChanged,
(false, true) => Drift::SaChanged,
(true, true) => Drift::BothChanged,
}
}
File diff suppressed because it is too large Load Diff
+107 -22
View File
@@ -1,6 +1,48 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
use thiserror::Error;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MigrationReport {
pub from_schema: u32,
pub to_schema: u32,
pub preserved_ports: Vec<PreservedPort>,
pub generated_endpoints: Vec<GeneratedEndpoint>,
pub orientation_defaults: Vec<OrientationDefault>,
pub required_resolutions: Vec<Id>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PreservedPort {
pub id: Id,
pub alias: String,
pub required_resolution: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeneratedEndpoint {
pub exchange: Id,
pub side: String,
pub port: Id,
pub owner: Id,
pub alias: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OrientationDefault {
pub exchange: Id,
pub oriented: bool,
}
#[derive(Debug, Clone)]
pub struct DecodeResult {
pub project: Project,
pub migration: Option<MigrationReport>,
}
#[derive(Debug, Error)]
pub enum FileError {
#[error("File operation failed: {0}")]
@@ -11,6 +53,11 @@ pub enum FileError {
Text(String),
#[error("Invalid project: {0}")]
Invalid(String),
#[error("Invalid project: {message}")]
Model {
message: String,
issues: Vec<validation::Issue>,
},
#[error(
"This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes."
)]
@@ -19,21 +66,30 @@ pub enum FileError {
Limit(String),
}
pub fn decode(bytes: &[u8]) -> Result<Project, FileError> {
decode_limited(bytes, crate::language::ParseLimits::UNLIMITED)
decode_with_report(bytes).map(|decoded| decoded.project)
}
pub fn decode_with_report(bytes: &[u8]) -> Result<DecodeResult, FileError> {
decode_with_report_limited(bytes, crate::language::ParseLimits::UNLIMITED)
}
/// Apply admission budgets before validating a candidate or its migrated form.
pub fn decode_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<Project, FileError> {
decode_with_report_limited(bytes, limits).map(|decoded| decoded.project)
}
/// Admission budgets cover the original graph and all generated migration data.
/// Decoding never rewrites a file or advances a hosted revision.
pub fn decode_with_report_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if bytes.len() > limits.max_source_bytes {
return Err(FileError::Limit(format!(
"Input exceeds {} bytes.",
limits.max_source_bytes
)));
}
let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
match header
.get("schema_version")
@@ -42,43 +98,63 @@ pub fn decode_limited(
Some(1) => {
let old = serde_json::from_slice(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_limited(old, limits).map_err(FileError::Invalid)?
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => serde_json::from_slice(bytes)?,
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1 or 2.".into(),
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
));
}
}
} else {
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
crate::language::parse_limited(text, limits).map_err(|diagnostics| {
crate::language::parse_with_report_limited(text, limits).map_err(|diagnostics| {
let detail = diagnostics
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
if diagnostics.iter().any(|diagnostic| {
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
}) {
if diagnostics
.iter()
.any(|d| matches!(d.code.as_str(), "model_limit" | "source_limit"))
{
FileError::Limit(detail)
} else {
FileError::Text(detail)
}
})?
};
limits.check_model(&p).map_err(FileError::Limit)?;
validate_project(&p)?;
Ok(p)
limits
.check_model(&decoded.project)
.map_err(FileError::Limit)?;
validate_project(&decoded.project)?;
Ok(decoded)
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
let normalized = normalize_v1_to_v2(old, limits).map_err(FileError::Invalid)?;
let mut decoded = migrate_v2_limited(normalized, limits)?;
if let Some(report) = &mut decoded.migration {
report.from_schema = 1;
}
Ok(decoded)
}
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
/// coordinates survive; every newly required identity is deterministic.
pub(crate) fn migrate_v1_limited(
fn normalize_v1_to_v2(
mut old: v1::Project,
limits: crate::language::ParseLimits,
) -> Result<Project, String> {
) -> Result<v2::model::Project, String> {
use v2::model::*;
check_v1_limits(&old, limits)?;
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
return Err(issue.message);
@@ -180,7 +256,10 @@ pub(crate) fn migrate_v1_limited(
id,
alias,
element: e.id,
position: e.position,
position: Position {
x: e.position.x,
y: e.position.y,
},
size: Size {
width: 224.,
height: 94.,
@@ -236,9 +315,9 @@ pub(crate) fn migrate_v1_limited(
}
*p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps;
}
if let Some(issue) = validation::validate(&p)
if let Some(issue) = v2::validation::validate(&p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
.find(|i| i.severity == v2::validation::Severity::Error)
{
return Err(std::format!(
"V1 migration cannot satisfy V2 validation: {}",
@@ -263,7 +342,7 @@ pub(crate) fn check_v1_limits(
let records = graph.saturating_add(old.elements.len()).saturating_add(2);
limits.check_counts(graph, records, steps)
}
fn migration_id(p: &Project, role: &str) -> Id {
fn migration_id(p: &v2::model::Project, role: &str) -> Id {
let mut suffix = 0;
loop {
let id = Id::new_v5(
@@ -478,12 +557,18 @@ fn validate_project(p: &Project) -> Result<(), FileError> {
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
{
return Err(FileError::Invalid(i.message));
return Err(FileError::Model {
message: i.message.clone(),
issues: vec![i],
});
}
Ok(())
}
pub fn load(path: &Path) -> Result<Project, FileError> {
decode(&std::fs::read(path)?)
load_with_report(path).map(|decoded| decoded.project)
}
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
decode_with_report(&std::fs::read(path)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {