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
+72 -3
View File
@@ -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;
}
+364
View File
@@ -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::<usize>()
+ 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 = &current["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 = &current["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::<usize>()
+ 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::<usize>();
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());
}
+109
View File
@@ -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"))
);
}