Migrate frozen data contracts and authored source to schema 4

This commit is contained in:
2026-09-06 03:39:02 +03:00
parent c5276b1943
commit 1230ab8a3e
21 changed files with 2006 additions and 126 deletions
+92 -13
View File
@@ -1,4 +1,4 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration. //! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
#[path = "migration_v2/source.rs"] #[path = "migration_v2/source.rs"]
mod v2_source; mod v2_source;
use crate::*; use crate::*;
@@ -340,6 +340,8 @@ fn reference_field(key: &str) -> bool {
| "referenced" | "referenced"
| "local" | "local"
| "field" | "field"
| "path"
| "supertype"
| "sender" | "sender"
| "receiver" | "receiver"
| "action" | "action"
@@ -378,10 +380,14 @@ fn enum_field(key: &str) -> bool {
| "message_kind" | "message_kind"
| "transition_kind" | "transition_kind"
| "side" | "side"
| "mechanism"
| "storage"
| "transmission"
| "acquisition"
) )
} }
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never /// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
/// adopts a model and never overwrites an editor draft. /// adopts a model and never overwrites an editor draft.
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> { pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
parse_limited(source, ParseLimits::UNLIMITED) parse_limited(source, ParseLimits::UNLIMITED)
@@ -546,7 +552,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
/// nested records are ordinary language fields; JSON documents are not embedded. /// nested records are ordinary language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String { pub fn format(project: &Project) -> String {
let mut out = std::format!( let mut out = std::format!(
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n", "arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias, project.alias,
quote(&project.name), quote(&project.name),
project.id, project.id,
@@ -1337,12 +1343,12 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)), .map(|(key, span)| ((d.id, key.clone()), span.start)),
); );
} }
let project = let decoded =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits) resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?; .map_err(|(_, error)| error)?;
Ok(ParsedDocument { Ok(ParsedDocument {
project, project: decoded.project,
migration: None, migration: decoded.migration,
locations, locations,
fields, fields,
}) })
@@ -1350,9 +1356,11 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> { fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry { let mut header = if entry {
self.take("arcadia")?; self.take("arcadia")?;
if self.text() != "3" { if !matches!(self.text(), "3" | "4") {
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'.")); return Err(self
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
} }
let schema_version = if self.text() == "3" { 3 } else { 4 };
self.cursor += 1; self.cursor += 1;
self.take(";")?; self.take(";")?;
let start = self.current().span.start; let start = self.current().span.start;
@@ -1365,7 +1373,7 @@ impl Parser<'_> {
self.take("{")?; self.take("{")?;
Some(SourceHeader { Some(SourceHeader {
project: Project { project: Project {
schema_version: SCHEMA_VERSION, schema_version,
id, id,
alias, alias,
name, name,
@@ -1540,7 +1548,9 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration], declarations: &[Declaration],
aliases: &BTreeMap<String, Id>, aliases: &BTreeMap<String, Id>,
limits: ParseLimits, limits: ParseLimits,
) -> Result<Project, (usize, Error)> { ) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
let mut nodes = Vec::new(); let mut nodes = Vec::new();
for (index, declaration) in declarations.iter().enumerate() { for (index, declaration) in declarations.iter().enumerate() {
(|| -> Result<(), Error> { (|| -> Result<(), Error> {
@@ -1561,6 +1571,9 @@ pub(crate) fn resolve_declarations(
}; };
match d.kind { match d.kind {
DeclKind::Element(kind, layer) => { DeclKind::Element(kind, layer) => {
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Element constructor was not part of schema3.".into()));
}
let description = let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?; take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let explicit_parent = d.fields.contains_key("parent"); let explicit_parent = d.fields.contains_key("parent");
@@ -1601,9 +1614,17 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy; *viewpoint = ScenarioViewpoint::MixedLegacy;
} }
let mut body = serde_json::to_value(initial).unwrap(); let mut body = serde_json::to_value(initial).unwrap();
let body_fields = let defaults = if legacy {
merge_fields(payload(&body), fields).map_err(&conversion_error)?; crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
} else { payload(&body) };
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
replace_payload(&mut body, body_fields); replace_payload(&mut body, body_fields);
if legacy {
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
return Ok(());
}
let body: ElementBody = serde_json::from_value(body) let body: ElementBody = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?; .map_err(|e| conversion_error(e.to_string()))?;
if body.kind() != kind || body.layer() != layer { if body.kind() != kind || body.layer() != layer {
@@ -1624,6 +1645,9 @@ pub(crate) fn resolve_declarations(
); );
} }
DeclKind::Relation(kind) => { DeclKind::Relation(kind) => {
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
}
let description = let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?; take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let required: &[&str] = if kind == RelationKind::FunctionalExchange { let required: &[&str] = if kind == RelationKind::FunctionalExchange {
@@ -1725,6 +1749,58 @@ pub(crate) fn resolve_declarations(
.map_err(|error| (index, error))?; .map_err(|error| (index, error))?;
target.nodes.insert(node.id, node); target.nodes.insert(node.id, node);
} }
if legacy {
let mut value = serde_json::to_value(&project).expect("project serialization");
value["elements"] = Value::Object(legacy_elements);
let migrated = serde_json::from_value(value)
.map_err(crate::persistence::FileError::Json)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
issues.first()
} else {
None
};
let index = issue
.and_then(|issue| issue.element)
.and_then(|id| declarations.iter().position(|d| d.id == id))
.unwrap_or(usize::MAX);
let offset = declarations.get(index).map_or(0, |d| {
issue
.and_then(|issue| issue.field.as_ref())
.and_then(|field| d.field_spans.get(field))
.map_or(d.offset, |span| span.start)
});
(
index,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset,
message: error.to_string(),
},
)
})?;
for (index, d) in declarations.iter().enumerate() {
if let DeclKind::Element(kind, layer) = d.kind {
let body = &migrated.project.elements[&d.id].body;
if body.kind() != kind || body.layer() != layer {
return Err((
index,
Error {
code: "invalid_source",
offset: d.offset,
message: "Declaration kind disagrees with its typed body.".into(),
},
));
}
}
}
return Ok(migrated);
}
limits.check_model(&project).map_err(|message| { limits.check_model(&project).map_err(|message| {
( (
usize::MAX, usize::MAX,
@@ -1735,7 +1811,10 @@ pub(crate) fn resolve_declarations(
}, },
) )
})?; })?;
Ok(project) Ok(crate::persistence::DecodeResult {
project,
migration: None,
})
} }
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) { fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
+40 -10
View File
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
fn project_source(body: &str) -> String { fn project_source(body: &str) -> String {
format!( format!(
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}" "arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
) )
} }
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
files: BTreeMap::from([ files: BTreeMap::from([
( (
"project.arcadia".into(), "project.arcadia".into(),
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(), "arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
), ),
( (
"children.arcadia".into(), "children.arcadia".into(),
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
#[test] #[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() { fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated"; let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let analysis = analyze(source); let analysis = analyze(source);
let ready = analysis let ready = analysis
.declarations .declarations
@@ -406,12 +406,34 @@ fn checked_in_source_project() -> SourceBundle {
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() { fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
let input = checked_in_source_project(); let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap(); let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!( assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
compiled.bundle, input, for (path, source) in &input.files {
"every example identity is already explicit" let unit = parse_unit(source, path == &input.entry, ParseLimits::UNLIMITED).unwrap();
); assert!(
unit.header
.as_ref()
.is_none_or(|header| header.id_insertion.is_none())
);
for declaration in unit.declarations {
assert!(
declaration.id_insertion.is_none(),
"every example identity is already explicit"
);
assert_eq!(
identity(&compiled.decoded.project, &declaration.alias),
declaration.id
);
}
}
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.bundle, compiled.bundle);
assert!(reopened.decoded.migration.is_none());
assert_eq!(compiled.diagnostics.len(), 2);
assert!( assert!(
compiled.diagnostics.is_empty(), compiled
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
"{:?}", "{:?}",
compiled.diagnostics compiled.diagnostics
); );
@@ -473,7 +495,15 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
ProjectLimits::default(), ProjectLimits::default(),
) )
.unwrap(); .unwrap();
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); assert_eq!(result.diagnostics.len(), 2);
assert!(
result
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
"{:?}",
result.diagnostics
);
assert_eq!( assert_eq!(
result.index.declarations[&assess].path, result.index.declarations[&assess].path,
"sa/assurance.arcadia" "sa/assurance.arcadia"
@@ -500,6 +530,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
"sa/participants.arcadia", "sa/participants.arcadia",
"views.arcadia", "views.arcadia",
] { ] {
assert_eq!(result.bundle.files[path], input.files[path]); assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
} }
} }
+6 -4
View File
@@ -11,10 +11,12 @@ but constructs and validates the frozen DTO, preserves located old-contract
errors, then calls `adapter.rs`. Original staged scenario/state defaults and errors, then calls `adapter.rs`. Original staged scenario/state defaults and
generic port/optional FE endpoint contracts therefore remain explicit. generic port/optional FE endpoint contracts therefore remain explicit.
`adapter.rs` budgets generated data, validates V2, transforms only the changed `adapter.rs` budgets generated data, validates V2 and transforms only the changed
payloads, and validates schema 3. Serialization between the two typed DTOs copies port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
unchanged fields; original input bytes are never deserialized into the new then performs the data-contract upgrade and validates schema 4. Serialization
`Project`. V1 normalization also constructs this V2 DTO before the shared stage. between compatibility DTOs copies unchanged fields; original input bytes are
never deserialized into the new `Project`. V1 normalization also constructs this
V2 DTO before the shared stages. Combined generation stays within one budget.
Do not update these frozen shapes when extending the current model. Add another Do not update these frozen shapes when extending the current model. Add another
versioned migration when the persistent contract changes again. Golden V2 versioned migration when the persistent contract changes again. Golden V2
+18 -13
View File
@@ -62,16 +62,9 @@ pub(crate) fn migrate(
}], }],
}); });
} }
let mut report = MigrationReport { let mut report = MigrationReport::data_upgrade(2);
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)?; let mut value = serde_json::to_value(&old)?;
value["schema_version"] = Value::from(SCHEMA_VERSION); value["schema_version"] = Value::from(3);
for element in old.elements.values() { for element in old.elements.values() {
if let Some(super::model::SharedBody::Port { if let Some(super::model::SharedBody::Port {
direction, direction,
@@ -204,11 +197,23 @@ pub(crate) fn migrate(
_ => {} _ => {}
} }
} }
let project: Project = serde_json::from_value(value)?; // Carry the endpoint-generation reference charge into the next migration;
limits.check_model(&project).map_err(FileError::Limit)?; // independent per-stage checks would undercount the combined generated graph.
super::super::validate_project(&project)?; let data_limits = ParseLimits {
max_step_references: limits
.max_step_references
.saturating_sub(additional_references),
..limits
};
let migrated = super::super::v3::migrate(serde_json::from_value(value)?, data_limits)?;
let data = migrated
.migration
.expect("the schema3 adapter reports its defaults");
report.semantic_defaults = data.semantic_defaults;
report.generated_interface_allocations = data.generated_interface_allocations;
report.unresolved_message_values = data.unresolved_message_values;
Ok(DecodeResult { Ok(DecodeResult {
project, project: migrated.project,
migration: Some(report), migration: Some(report),
}) })
} }
+33
View File
@@ -0,0 +1,33 @@
# Schema 3 to 4 reader
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
The small DTOs in `mod.rs` own the changed data payloads and old message values;
closed element/relation constructor lists prevent new vocabulary from entering
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
Nature, bounded types, field defaults/direction/storage, and single supertypes
receive explicit documented defaults. Interface lists become identified ordered
allocations with UUID v5 namespace equal to the project UUID and name
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
V2 and V3 inputs so the migration route cannot change the generated identity.
Existing IDs never move; deterministic collisions fail with a located diagnostic.
Aliases use the normal deterministic collision suffix. Unique legacy message
paths are qualified; ambiguous annotations retain their expression and warning.
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
an explicit schema3 candidate and then runs this reader before current decoding.
Generated object, record and reference budgets cover the combined stages.
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
but chooses frozen source3 payload defaults and rejects new constructors. Both
single-file parsing and `source_project::compile_bundle` take this explicit path.
The bundle compiler first stabilizes omitted IDs, then performs targeted token
edits in `source_project/source_migration.rs`. Original files, includes, lexical
ownership and comments survive. New allocation declarations stay in the source
interface's module. The returned source and index describe schema4 bytes; loading
does not rewrite original disk files. Canonical output and second-save stability
are schema4. No whole-project formatting fallback handles authored source3.
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
original V2 golden files remain unchanged.
+630
View File
@@ -0,0 +1,630 @@
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
//! unchanged typed records are shared after a closed old-constructor check.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport};
use crate::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project3 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Element3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub parent: Option<Id>,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Relation3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
struct Strict;
impl<'de> serde::de::Visitor<'de> for Strict {
type Value = Value;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a value with unique object fields")
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
Ok(v.into())
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
serde_json::Number::from_f64(v)
.map(Value::Number)
.ok_or_else(|| E::custom("Non-finite number"))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
Ok(v.into())
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
Ok(v.into())
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = Vec::new();
while let Some(v) = a.next_element::<StrictValue>()? {
out.push(v.0);
}
Ok(Value::Array(out))
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = serde_json::Map::new();
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
if out.insert(k.clone(), v.0).is_some() {
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
}
}
Ok(Value::Object(out))
}
}
d.deserialize_any(Strict)
}
struct StrictValue(Value);
impl<'de> Deserialize<'de> for StrictValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
strict_value(d).map(Self)
}
}
#[derive(Deserialize)]
enum ItemKind3 {
Information,
Material,
Energy,
Event,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
enum Definition3 {
Boolean,
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Item3 {
kind: ItemKind3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Type3 {
definition: Definition3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Field3 {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Interface3 {
items: Vec<Id>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MessageValue3 {
field: Id,
expression: String,
}
pub(crate) fn relation_allowed(name: &str) -> bool {
matches!(
name,
"Allocation"
| "PortAllocation"
| "Interaction"
| "CapabilityInvolvement"
| "ChainInvolvement"
| "RoleRealization"
| "RoleAssignment"
| "FunctionalExchange"
| "BoundaryExchange"
| "MissionCapability"
| "CapabilityInclude"
| "CapabilityExtend"
| "CapabilityGeneralization"
| "MissionInvolvement"
| "SequenceLink"
| "StateTransition"
| "Availability"
| "ExchangeAllocation"
| "InterfaceProvision"
| "InterfaceRequirement"
| "StateAction"
)
}
pub(crate) fn element_allowed(name: &str) -> bool {
matches!(
name,
"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"
)
}
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
Some(match kind {
Kind::ExchangeItem => json!({"kind":"Information"}),
Kind::DataType => json!({"definition":"String"}),
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
Kind::Interface => json!({"items":[]}),
_ => return None,
})
}
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
let message = message.into();
FileError::Model {
message: format!("V3 {code} at {id}.{field}: {message}"),
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: Some(id),
code: code.into(),
field: Some(field.into()),
message,
}],
}
}
fn singleton(value: &Value) -> Option<(&str, &Value)> {
let object = value.as_object()?;
(object.len() == 1)
.then(|| object.iter().next().unwrap())
.map(|(k, v)| (k.as_str(), v))
}
fn old_payload<T: serde::de::DeserializeOwned>(
id: Id,
field: &str,
value: Value,
) -> Result<T, FileError> {
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
}
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
if old.schema_version != 3 {
return Err(FileError::Invalid("Expected schema_version 3.".into()));
}
let graph = old.elements.len().saturating_add(old.relations.len());
let records = graph
.saturating_add(old.diagrams.len())
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
.saturating_add(old.traces.len())
.saturating_add(old.artifact_links.len())
.saturating_add(1);
limits
.check_counts(graph, records, 0)
.map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(3);
let mut value = serde_json::to_value(&old)?;
let mut allocations = Vec::new();
for (key, e) in &old.elements {
if *key != e.id {
return Err(at(
e.id,
"id",
"identity",
"Element map key differs from its identity.",
));
}
let mut body = e.body.clone();
let (layer, inner) = singleton(&body)
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one OA or SA constructor.",
)
})?;
let layer = layer.to_owned();
let outer = inner
.as_str()
.or_else(|| singleton(inner).map(|(name, _)| name));
if !outer.is_some_and(|name| {
if layer == "Oa" {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "Mission"
| "Process"
| "Shared"
)
} else {
matches!(
name,
"System"
| "Actor"
| "Function"
| "Capability"
| "Mission"
| "FunctionalChain"
| "Shared"
)
}
}) {
return Err(at(
e.id,
"body",
"legacy_shape",
"Unknown schema3 layer constructor.",
));
}
if let Some(("Shared", shared)) = singleton(inner) {
let name = shared
.as_str()
.or_else(|| singleton(shared).map(|(name, _)| name))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one schema3 shared constructor.",
)
})?
.to_owned();
if !matches!(
name.as_str(),
"ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Event"
| "Region"
| "State"
| "Constraint"
) {
return Err(at(
e.id,
"body",
"legacy_shape",
format!("Unknown schema3 shared constructor '{name}'."),
));
}
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
let changed = match name.as_str() {
"ExchangeItem" => {
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
let (kind, mechanism) = match old.kind {
ItemKind3::Information => ("Information", "Unspecified"),
ItemKind3::Material => ("Material", "Unspecified"),
ItemKind3::Energy => ("Energy", "Unspecified"),
ItemKind3::Event => ("Information", "Event"),
};
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
}
"DataType" => {
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
let definition = match old.definition {
Definition3::Boolean => json!("Boolean"),
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
Definition3::String => {
json!({"String":{"minimum_length":0,"maximum_length":null}})
}
Definition3::Enumeration { literals } => {
json!({"Enumeration":{"literals":literals}})
}
Definition3::Record => json!({"Record":{"supertype":null}}),
};
Some(json!({"definition":definition,"default":null,"unit":""}))
}
"DataField" => {
let old: Field3 = old_payload(e.id, "body", payload)?;
Some(
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
)
}
"Interface" => {
let old: Interface3 = old_payload(e.id, "items", payload)?;
let mut seen = BTreeSet::new();
for (order, item) in old.items.into_iter().enumerate() {
if !seen.insert(item) {
return Err(at(
e.id,
"items",
"duplicate_reference",
"Interface items must be unique.",
));
}
allocations.push((e.id, item, order));
}
Some(json!({"supertype":null}))
}
"Occurrence" => {
let mut payload = payload;
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
let old: Vec<MessageValue3> =
old_payload(e.id, "body.values", values.clone())?;
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
}
Some(payload)
}
_ => None,
};
if let Some(changed) = changed {
let description = match name.as_str() {
"ExchangeItem" if changed["mechanism"] == "Event" => {
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
}
"ExchangeItem" => {
"Nature is retained; mechanism is Unspecified and supertype is absent."
}
"DataType" => {
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
}
"DataField" => {
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
}
"Interface" => {
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
}
_ => {
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
}
};
body[&layer]["Shared"][&name] = changed;
report
.semantic_defaults
.push(crate::persistence::SemanticDefault {
object: e.id,
field: "body".into(),
description: description.into(),
});
}
}
// Current deserialization checks the reused, unchanged outer/typed parts.
let _: ElementBody = serde_json::from_value(body.clone())
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
value["elements"][key.to_string()]["body"] = body;
}
for (key, r) in &old.relations {
if *key != r.id {
return Err(at(
r.id,
"id",
"identity",
"Relation map key differs from its identity.",
));
}
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
return Err(at(
r.id,
"body",
"legacy_shape",
"Relation constructor was not part of schema3.",
));
}
}
value["schema_version"] = SCHEMA_VERSION.into();
let mut project: Project = serde_json::from_value(value)?;
let mut references = project
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum::<usize>()
.saturating_add(allocations.len().saturating_mul(2));
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len())
.saturating_add(allocations.len()),
project.identities().len().saturating_add(allocations.len()),
references,
)
.map_err(FileError::Limit)?;
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
for (source, target, order) in allocations {
let id = Id::new_v5(
&project.id,
format!("v2-interface-item:{source}:{target}").as_bytes(),
);
if !ids.insert(id) {
return Err(at(
source,
"items",
"identity_collision",
format!(
"Deterministic interface allocation {id} already belongs to another object."
),
));
}
let name = format!("{} · {}", project.named(source), project.named(target));
let alias = project.fresh_alias(
&format!(
"{}_{}_allocation",
project
.elements
.get(&source)
.map_or("interface", |e| e.alias.as_str()),
project
.elements
.get(&target)
.map_or("item", |e| e.alias.as_str())
),
"interface_item_allocation",
id,
);
project.relations.insert(
id,
Relation {
id,
alias: alias.clone(),
name,
description: String::new(),
body: RelationBody::InterfaceItemAllocation {
source,
target,
order: order.try_into().map_err(|_| {
at(source, "items", "model_limit", "Too many interface items.")
})?,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
},
);
report.generated_interface_allocations.push(
crate::persistence::GeneratedInterfaceAllocation {
id,
source,
target,
alias,
order: order as u32,
},
);
}
let annotations: Vec<_> = project
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
values,
..
},
..
}) => Some((e.id, *exchange, values.clone())),
_ => None,
})
.collect();
for (owner, exchange, values) in annotations {
let Some(relation) = project.relations.get(&exchange) else {
continue;
};
for (index, value) in values.into_iter().enumerate() {
let resolution =
crate::data::resolve_legacy_field_path(&project, relation, value.field)
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
if !path.is_empty() {
references = references.saturating_add(path.len());
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len()),
project.identities().len(),
references,
)
.map_err(FileError::Limit)?;
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project
.elements
.get_mut(&owner)
.and_then(Element::shared_mut)
{
values[index].path = path;
}
}
} else if matches!(
resolution,
crate::data::LegacyFieldPathResolution::Ambiguous
) {
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
}
}
}
limits.check_model(&project).map_err(FileError::Limit)?;
super::validate_project(&project)?;
Ok(DecodeResult {
project,
migration: Some(report),
})
}
+49 -2
View File
@@ -5,6 +5,8 @@ use thiserror::Error;
#[path = "migration_v2/mod.rs"] #[path = "migration_v2/mod.rs"]
pub(crate) mod v2; pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited; pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[path = "migration_v3/mod.rs"]
pub(crate) mod v3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -15,6 +17,50 @@ pub struct MigrationReport {
pub generated_endpoints: Vec<GeneratedEndpoint>, pub generated_endpoints: Vec<GeneratedEndpoint>,
pub orientation_defaults: Vec<OrientationDefault>, pub orientation_defaults: Vec<OrientationDefault>,
pub required_resolutions: Vec<Id>, pub required_resolutions: Vec<Id>,
#[serde(default)]
pub semantic_defaults: Vec<SemanticDefault>,
#[serde(default)]
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
#[serde(default)]
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
}
impl MigrationReport {
pub(crate) fn data_upgrade(from_schema: u32) -> Self {
Self {
from_schema,
to_schema: SCHEMA_VERSION,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
semantic_defaults: vec![],
generated_interface_allocations: vec![],
unresolved_message_values: vec![],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticDefault {
pub object: Id,
pub field: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeneratedInterfaceAllocation {
pub id: Id,
pub source: Id,
pub target: Id,
pub alias: String,
pub order: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnresolvedMessageValue {
pub occurrence: Id,
pub field: Id,
pub reason: String,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -101,13 +147,14 @@ pub fn decode_with_report_limited(
migrate_v1_with_report_limited(old, limits)? migrate_v1_with_report_limited(old, limits)?
} }
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?, Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => DecodeResult { Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => DecodeResult {
project: serde_json::from_slice(bytes)?, project: serde_json::from_slice(bytes)?,
migration: None, migration: None,
}, },
_ => { _ => {
return Err(FileError::Invalid( return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(), "Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
)); ));
} }
} }
+1 -1
View File
@@ -999,7 +999,7 @@ mod transaction_tests {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
fs::write( fs::write(
directory.path().join("project.arcadia"), directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
) )
.unwrap(); .unwrap();
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap(); fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
+52 -20
View File
@@ -1,9 +1,11 @@
//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8 //! Pure, bounded source projects. All includes resolve against supplied UTF-8
//! documents; this module never reads a filesystem, URL, or process directory. //! documents; this module never reads a filesystem, URL, or process directory.
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation}; use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span}; use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
#[path = "source_project/source_migration.rs"]
mod source_migration;
#[path = "source_project/source_structure.rs"] #[path = "source_project/source_structure.rs"]
mod source_structure; mod source_structure;
@@ -278,21 +280,24 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
} }
} }
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs. /// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
/// Missing/unreachable files and every admission or engineering error leave the /// Missing/unreachable files and every admission or engineering error leave the
/// input untouched. V1/V2 single-file migration remains in `language::parse`. /// input untouched. V1/V2 single-file migration remains in `language::parse`.
pub fn compile_bundle( pub fn compile_bundle(
bundle: &SourceBundle, bundle: &SourceBundle,
limits: ProjectLimits, limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> { ) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
let (compiled, insertions) = compile_once(bundle, limits)?; let (mut compiled, insertions) = compile_once(bundle, limits)?;
if insertions.is_empty() { if !insertions.is_empty() {
return Ok(compiled); let mut stable = bundle.clone();
apply_edits(&mut stable, insertions)?;
// Re-index actual returned bytes before performing the schema upgrade.
compiled = compile_once(&stable, limits)?.0;
} }
let mut stable = bundle.clone(); if compiled.decoded.migration.is_some() {
apply_edits(&mut stable, insertions)?; return source_migration::upgrade(&compiled, limits);
// Re-index the actual returned bytes. Never expose offsets into pre-ID text. }
compile_once(&stable, limits).map(|(compiled, _)| compiled) Ok(compiled)
} }
fn compile_once( fn compile_once(
@@ -568,7 +573,7 @@ fn compile_once(
span, span,
}), }),
}; };
let project = let decoded =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model) language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| { .map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry); let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
@@ -578,7 +583,7 @@ fn compile_once(
related: Vec::new(), related: Vec::new(),
}] }]
})?; })?;
let diagnostics = model_diagnostics(bundle, &index, &project); let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let errors: Vec<_> = diagnostics let errors: Vec<_> = diagnostics
.iter() .iter()
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error) .filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
@@ -590,10 +595,7 @@ fn compile_once(
Ok(( Ok((
CompiledSourceProject { CompiledSourceProject {
bundle: bundle.clone(), bundle: bundle.clone(),
decoded: DecodeResult { decoded,
project,
migration: None,
},
index, index,
diagnostics, diagnostics,
}, },
@@ -718,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
Ok(()) Ok(())
} }
/// Canonical, readable source3 export. Like `language::format`, the caller is /// Canonical, readable source4 export. Like `language::format`, the caller is
/// responsible for supplying a valid model; compilation checks the result. /// responsible for supplying a valid model; compilation checks the result.
/// Existing source layout is preserved by `update_bundle`, not this formatter. /// Existing source layout is preserved by `update_bundle`, not this formatter.
pub fn format_bundle(project: &Project) -> SourceBundle { pub fn format_bundle(project: &Project) -> SourceBundle {
@@ -734,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.push_str(&unindent(&declaration)); .push_str(&unindent(&declaration));
} }
let mut entry = format!( let mut entry = format!(
"arcadia 3;\n{}{{\n description {};\n", "arcadia 4;\n{}{{\n description {};\n",
project_header(project), project_header(project),
quote(&project.description) quote(&project.description)
); );
@@ -923,6 +925,7 @@ pub fn update_bundle_with_identity_map(
after: new_text, after: new_text,
old_aliases: &old_aliases, old_aliases: &old_aliases,
new_aliases: &new_aliases, new_aliases: &new_aliases,
legacy_value_paths: false,
}; };
let local = edits.entry(path.clone()).or_default(); let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local); update.header(&actual.header, &previous.header, &next.header, local);
@@ -951,6 +954,7 @@ pub fn update_bundle_with_identity_map(
after: &after, after: &after,
old_aliases: &old_aliases, old_aliases: &old_aliases,
new_aliases: &new_aliases, new_aliases: &new_aliases,
legacy_value_paths: false,
}; };
let mut local = Vec::new(); let mut local = Vec::new();
update.header( update.header(
@@ -1119,6 +1123,9 @@ struct SyntaxUpdate<'a> {
after: &'a str, after: &'a str,
old_aliases: &'a BTreeMap<String, String>, old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>, new_aliases: &'a BTreeMap<String, String>,
// Source3 migration only adds path qualification; the existing field identity
// must keep its authored argument trivia during that one-time transformation.
legacy_value_paths: bool,
} }
impl SyntaxUpdate<'_> { impl SyntaxUpdate<'_> {
fn header( fn header(
@@ -1338,11 +1345,11 @@ impl SyntaxUpdate<'_> {
let previous = before.unwrap_or(&[]); let previous = before.unwrap_or(&[]);
let old_keys: Option<Vec<_>> = previous let old_keys: Option<Vec<_>> = previous
.iter() .iter()
.map(|item| item_key(item, self.before, self.old_aliases)) .map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
.collect(); .collect();
let new_keys: Option<Vec<_>> = after let new_keys: Option<Vec<_>> = after
.iter() .iter()
.map(|item| item_key(item, self.after, self.new_aliases)) .map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
.collect(); .collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| { let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len() old.iter().collect::<BTreeSet<_>>().len() == old.len()
@@ -1449,6 +1456,7 @@ fn item_key(
value: &language::EditValue, value: &language::EditValue,
source: &str, source: &str,
aliases: &BTreeMap<String, String>, aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
) -> Option<String> { ) -> Option<String> {
use language::EditKind; use language::EditKind;
match &value.kind { match &value.kind {
@@ -1462,7 +1470,31 @@ fn item_key(
.find_map(|key| { .find_map(|key| {
fields.get(key).and_then(|field| { fields.get(key).and_then(|field| {
if let EditKind::Reference(alias) = &field.value.kind { if let EditKind::Reference(alias) = &field.value.kind {
aliases.get(alias).map(|id| format!("{key}:{id}")) aliases.get(alias).map(|id| {
if key == "field" && !legacy_value_paths {
let path = fields
.get("path")
.and_then(|field| {
if let EditKind::List(path) = &field.value.kind {
path.iter()
.map(|hop| {
if let EditKind::Reference(alias) = &hop.kind {
aliases.get(alias).cloned()
} else {
None
}
})
.collect::<Option<Vec<_>>>()
} else {
None
}
})
.unwrap_or_default();
format!("field:{}:{id}", path.join("/"))
} else {
format!("{key}:{id}")
}
})
} else { } else {
None None
} }
@@ -0,0 +1,125 @@
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
use super::*;
pub(super) fn upgrade(
current: &CompiledSourceProject,
limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
let report = current
.decoded
.migration
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
.map(|default| default.object)
.collect();
let aliases: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, alias)| (alias.to_owned(), id.to_string()))
.collect();
let mut edits: Edits = BTreeMap::new();
for (path, source) in &current.bundle.files {
let declarations =
language::edit_declarations(source, path == &current.bundle.entry, limits.model)
.map_err(|diagnostic| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *diagnostic,
related: vec![],
}]
})?;
for (id, actual) in declarations {
if !changed.contains(&id) {
continue;
}
let Some(after) = canonical.get(&id) else {
continue;
};
let mut next = language::edit_declaration(after).map_err(|diagnostic| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *diagnostic,
related: vec![],
}]
})?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
&& let language::EditKind::Record(fields) = &mut next.fields.kind
{
fields.remove("parent");
}
let update = SyntaxUpdate {
source,
before: source,
after,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
};
update.value(
&actual.fields,
Some(&actual.fields),
&next.fields,
edits.entry(path.clone()).or_default(),
);
}
}
let source = &current.bundle.files[&current.bundle.entry];
let version = significant(source)
.get(1)
.map(|token| token.span)
.expect("a parsed version token");
edits
.entry(current.bundle.entry.clone())
.or_default()
.push((version, "4".into()));
// New allocation relations are emitted into the allocating interface's
// existing module, outside its lexical element body. Existing source never moves.
let mut added: BTreeMap<String, String> = BTreeMap::new();
for allocation in &report.generated_interface_allocations {
let path = current
.index
.declarations
.get(&allocation.source)
.map(|location| location.path.clone())
.unwrap_or_else(|| current.bundle.entry.clone());
let ending = newline(&current.bundle.files[&path]);
let text = added.entry(path).or_default();
text.push_str(ending);
text.push_str(&canonical[&allocation.id].replace('\n', ending));
}
for (path, text) in added {
let offset = if path == current.bundle.entry {
current.index.project_body.span.end
} else {
current.bundle.files[&path].len()
};
edits.entry(path).or_default().push((
Span {
start: offset,
end: offset,
},
text,
));
}
let mut bundle = current.bundle.clone();
apply_edits(&mut bundle, edits)?;
let (mut result, insertions) = compile_once(&bundle, limits)?;
if !insertions.is_empty() || result.decoded.project != *project {
return Err(vec![problem(
&bundle,
&bundle.entry,
Span::default(),
"migration_source_mismatch",
"Targeted source3 upgrade did not reproduce the migrated project.",
)]);
}
result.decoded.migration = Some(report.clone());
Ok(result)
}
+38
View File
@@ -0,0 +1,38 @@
// Frozen authored schema3 compatibility input. Do not rewrite as current syntax.
arcadia 3;
project legacy_data "Legacy data contract" id "00000000-0000-0000-0000-000000000001" {
sa_function produce "Produce" id "00000000-0000-0000-0000-000000000002" {}
sa_function consume "Consume" id "00000000-0000-0000-0000-000000000003" {}
sa_exchange_item packet "Position event" id "00000000-0000-0000-0000-000000000004" {
kind Event;
// Reused record parameters retain their own identities.
sa_data_field origin "Origin" id "00000000-0000-0000-0000-000000000007" { type_ref { Named @coordinate; }; }
sa_data_field destination "Destination" id "00000000-0000-0000-0000-000000000008" { type_ref { Named @coordinate; }; }
}
sa_data_type coordinate "Coordinate" id "00000000-0000-0000-0000-000000000005" {
definition Record;
sa_data_field latitude "Latitude" id "00000000-0000-0000-0000-000000000006" { type_ref Real; unit "deg"; }
}
sa_interface sender_contract "Sender contract" id "00000000-0000-0000-0000-000000000009" { items [@packet]; }
sa_interface receiver_contract "Receiver contract" id "00000000-0000-0000-0000-00000000000a" { items [@packet]; }
sa_port output "Output" id "00000000-0000-0000-0000-00000000000b" {
parent @produce; contract { FunctionOutput { outgoing_items [@packet]; }; }; legacy none;
}
sa_port input "Input" id "00000000-0000-0000-0000-00000000000c" {
parent @consume; contract { FunctionInput { incoming_items [@packet]; }; }; legacy none;
}
functional_exchange position_exchange "Position exchange" id "00000000-0000-0000-0000-00000000000d" {
source @produce; target @consume; source_port @output; target_port @input; items [@packet];
}
sa_scenario scenario "Position scenario" id "00000000-0000-0000-0000-00000000000e" {
viewpoint Functional; steps [@message];
sa_lifeline sender "Sender" id "00000000-0000-0000-0000-00000000000f" { represented @produce; }
sa_lifeline receiver "Receiver" id "00000000-0000-0000-0000-000000000010" { represented @consume; }
sa_occurrence message "Position" id "00000000-0000-0000-0000-000000000011" {
body { Exchange {
exchange @position_exchange; sender @sender; receiver @receiver;
values [{ field @latitude; expression "navigation.latitude"; }];
}; };
}
}
}
+312
View File
@@ -0,0 +1,312 @@
{
"schema_version": 3,
"id": "00000000-0000-0000-0000-000000000001",
"alias": "legacy_data",
"name": "Legacy data contract",
"description": "",
"elements": {
"00000000-0000-0000-0000-000000000002": {
"id": "00000000-0000-0000-0000-000000000002",
"alias": "produce",
"name": "Produce",
"description": "",
"parent": null,
"body": {
"Sa": "Function"
}
},
"00000000-0000-0000-0000-000000000003": {
"id": "00000000-0000-0000-0000-000000000003",
"alias": "consume",
"name": "Consume",
"description": "",
"parent": null,
"body": {
"Sa": "Function"
}
},
"00000000-0000-0000-0000-000000000004": {
"id": "00000000-0000-0000-0000-000000000004",
"alias": "packet",
"name": "Position event",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"ExchangeItem": {
"kind": "Event"
}
}
}
}
},
"00000000-0000-0000-0000-000000000005": {
"id": "00000000-0000-0000-0000-000000000005",
"alias": "coordinate",
"name": "Coordinate",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"DataType": {
"definition": "Record"
}
}
}
}
},
"00000000-0000-0000-0000-000000000006": {
"id": "00000000-0000-0000-0000-000000000006",
"alias": "latitude",
"name": "Latitude",
"description": "",
"parent": "00000000-0000-0000-0000-000000000005",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": "Real",
"minimum": 1,
"maximum": 1,
"unit": "deg"
}
}
}
}
},
"00000000-0000-0000-0000-000000000007": {
"id": "00000000-0000-0000-0000-000000000007",
"alias": "origin",
"name": "Origin",
"description": "",
"parent": "00000000-0000-0000-0000-000000000004",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": {
"Named": "00000000-0000-0000-0000-000000000005"
},
"minimum": 1,
"maximum": 1,
"unit": ""
}
}
}
}
},
"00000000-0000-0000-0000-000000000008": {
"id": "00000000-0000-0000-0000-000000000008",
"alias": "destination",
"name": "Destination",
"description": "",
"parent": "00000000-0000-0000-0000-000000000004",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": {
"Named": "00000000-0000-0000-0000-000000000005"
},
"minimum": 1,
"maximum": 1,
"unit": ""
}
}
}
}
},
"00000000-0000-0000-0000-000000000009": {
"id": "00000000-0000-0000-0000-000000000009",
"alias": "sender_contract",
"name": "Sender contract",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Interface": {
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000a": {
"id": "00000000-0000-0000-0000-00000000000a",
"alias": "receiver_contract",
"name": "Receiver contract",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Interface": {
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000b": {
"id": "00000000-0000-0000-0000-00000000000b",
"alias": "output",
"name": "Output",
"description": "",
"parent": "00000000-0000-0000-0000-000000000002",
"body": {
"Sa": {
"Shared": {
"Port": {
"contract": {
"FunctionOutput": {
"outgoing_items": [
"00000000-0000-0000-0000-000000000004"
]
}
},
"legacy": null
}
}
}
}
},
"00000000-0000-0000-0000-00000000000c": {
"id": "00000000-0000-0000-0000-00000000000c",
"alias": "input",
"name": "Input",
"description": "",
"parent": "00000000-0000-0000-0000-000000000003",
"body": {
"Sa": {
"Shared": {
"Port": {
"contract": {
"FunctionInput": {
"incoming_items": [
"00000000-0000-0000-0000-000000000004"
]
}
},
"legacy": null
}
}
}
}
},
"00000000-0000-0000-0000-00000000000e": {
"id": "00000000-0000-0000-0000-00000000000e",
"alias": "scenario",
"name": "Position scenario",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Scenario": {
"viewpoint": "Functional",
"context": null,
"precondition": "",
"postcondition": "",
"steps": [
"00000000-0000-0000-0000-000000000011"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000f": {
"id": "00000000-0000-0000-0000-00000000000f",
"alias": "sender",
"name": "Sender",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Lifeline": {
"represented": "00000000-0000-0000-0000-000000000002"
}
}
}
}
},
"00000000-0000-0000-0000-000000000010": {
"id": "00000000-0000-0000-0000-000000000010",
"alias": "receiver",
"name": "Receiver",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Lifeline": {
"represented": "00000000-0000-0000-0000-000000000003"
}
}
}
}
},
"00000000-0000-0000-0000-000000000011": {
"id": "00000000-0000-0000-0000-000000000011",
"alias": "message",
"name": "Position",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Occurrence": {
"body": {
"Exchange": {
"exchange": "00000000-0000-0000-0000-00000000000d",
"sender": "00000000-0000-0000-0000-00000000000f",
"receiver": "00000000-0000-0000-0000-000000000010",
"message_kind": "Asynchronous",
"reply_to": null,
"values": [
{
"field": "00000000-0000-0000-0000-000000000006",
"expression": "navigation.latitude"
}
]
}
},
"precondition": "",
"postcondition": ""
}
}
}
}
}
},
"relations": {
"00000000-0000-0000-0000-00000000000d": {
"id": "00000000-0000-0000-0000-00000000000d",
"alias": "position_exchange",
"name": "Position exchange",
"description": "",
"body": {
"FunctionalExchange": {
"source": "00000000-0000-0000-0000-000000000002",
"target": "00000000-0000-0000-0000-000000000003",
"source_port": "00000000-0000-0000-0000-00000000000b",
"target_port": "00000000-0000-0000-0000-00000000000c",
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
},
"diagrams": {},
"artifact_links": {},
"traces": {}
}
+102 -9
View File
@@ -168,6 +168,20 @@ project mission "Harbor safety — 港 🌊" {
.find(|e| e.alias == "event_record") .find(|e| e.alias == "event_record")
.unwrap() .unwrap()
.id; .id;
let associated = p
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
p.elements.get_mut(&associated).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
p.relate(
RelationKind::DataAssociation,
"Related record",
record,
associated,
);
for (index, type_ref) in [ for (index, type_ref) in [
TypeRef::Boolean, TypeRef::Boolean,
TypeRef::Integer, TypeRef::Integer,
@@ -190,18 +204,30 @@ project mission "Harbor safety — 港 🌊" {
minimum: 1, minimum: 1,
maximum: Some(5), maximum: Some(5),
unit: "unit".into(), unit: "unit".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
}; };
} }
for definition in [ for definition in [
DataTypeDefinition::Boolean, DataTypeDefinition::Boolean,
DataTypeDefinition::Integer, DataTypeDefinition::Integer {
DataTypeDefinition::String, minimum: None,
maximum: None,
},
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
] { ] {
let id = p let id = p
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None) .add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
.unwrap(); .unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
SharedBody::DataType { definition }; definition,
default: None,
unit: String::new(),
};
} }
for kind in [ for kind in [
ExchangeItemKind::Material, ExchangeItemKind::Material,
@@ -216,7 +242,11 @@ project mission "Harbor safety — 港 🌊" {
None, None,
) )
.unwrap(); .unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind }; *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
};
} }
let scenario = p let scenario = p
.elements .elements
@@ -461,7 +491,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
#[test] #[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() { fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [ for (source, expected) in [
("arcadia 4; project mission \"x\" {}", "Unsupported schema"), ("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
( (
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }", "arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field", "Unknown field",
@@ -765,9 +795,27 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json); assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap(); let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap(); let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 3); assert_eq!(retained["schema_version"], 4);
retained["schema_version"] = 2.into(); retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
.values()
.filter_map(|relation| {
if let RelationBody::InterfaceItemAllocation {
source,
target,
order,
..
} = relation.body
{
Some((source, target, order))
} else {
None
}
})
.collect();
for element in retained["elements"].as_object_mut().unwrap().values_mut() { for element in retained["elements"].as_object_mut().unwrap().values_mut() {
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
for layer in ["Oa", "Sa"] { for layer in ["Oa", "Sa"] {
let Some(shared) = element["body"] let Some(shared) = element["body"]
.get_mut(layer) .get_mut(layer)
@@ -779,6 +827,46 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(port["contract"], "Legacy"); assert_eq!(port["contract"], "Legacy");
*port = port["legacy"].clone(); *port = port["legacy"].clone();
} }
if let Some(item) = shared
.get_mut("ExchangeItem")
.and_then(serde_json::Value::as_object_mut)
{
if item["mechanism"] == "Event" {
item.insert("kind".into(), "Event".into());
}
item.remove("mechanism");
item.remove("supertype");
}
if let Some(kind) = shared
.get_mut("DataType")
.and_then(serde_json::Value::as_object_mut)
{
kind.remove("default");
kind.remove("unit");
let definition = &kind["definition"];
if let Some(name) = ["Integer", "Real", "String", "Record"]
.into_iter()
.find(|name| definition.get(name).is_some())
{
kind.insert("definition".into(), name.into());
}
}
if let Some(field) = shared
.get_mut("DataField")
.and_then(serde_json::Value::as_object_mut)
{
for key in ["direction", "storage", "default"] {
field.remove(key);
}
}
if let Some(interface) = shared.get_mut("Interface") {
let mut entries: Vec<_> = allocations
.iter()
.filter(|(source, _, _)| *source == element_id)
.collect();
entries.sort_by_key(|(_, _, order)| *order);
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
}
if let Some(scenario) = shared if let Some(scenario) = shared
.get_mut("Scenario") .get_mut("Scenario")
.and_then(serde_json::Value::as_object_mut) .and_then(serde_json::Value::as_object_mut)
@@ -797,6 +885,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
} }
} }
} }
retained["relations"]
.as_object_mut()
.unwrap()
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
for relation in retained["relations"].as_object_mut().unwrap().values_mut() { for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") { if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
assert_eq!(boundary["oriented"], true); assert_eq!(boundary["oriented"], true);
@@ -826,7 +918,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
); );
assert_eq!( assert_eq!(
from_json.relations.len(), from_json.relations.len(),
original["relations"].as_object().unwrap().len() original["relations"].as_object().unwrap().len() + allocations.len()
); );
assert_eq!( assert_eq!(
from_json.diagrams.len(), from_json.diagrams.len(),
@@ -899,7 +991,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
assert_eq!(from_text, from_json); assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text); assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json); assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, 3); assert_eq!(from_text.schema_version, 4);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa)); assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap(); let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() { for (id, record) in raw["elements"].as_object().unwrap() {
@@ -1100,6 +1192,7 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
} }
if code.trim_start().starts_with("arcadia 2;") if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;") || code.trim_start().starts_with("arcadia 3;")
|| code.trim_start().starts_with("arcadia 4;")
{ {
read(&code); read(&code);
examples += 1; examples += 1;
+12 -5
View File
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
assert_eq!(decoded.project, from_source.project); assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration); assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap(); let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 3)); assert_eq!((report.from_schema, report.to_schema), (2, 4));
assert_eq!(report.preserved_ports.len(), 4); assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty()); assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1); assert_eq!(report.orientation_defaults.len(), 1);
@@ -56,6 +56,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
+ old["traces"].as_object().unwrap().len() + old["traces"].as_object().unwrap().len()
+ old["artifact_links"].as_object().unwrap().len() + old["artifact_links"].as_object().unwrap().len()
+ 1 + 1
+ report.generated_interface_allocations.len()
); );
let mut current = serde_json::to_value(&decoded.project).unwrap(); let mut current = serde_json::to_value(&decoded.project).unwrap();
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() { for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
@@ -143,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
} }
} }
let canonical = language::format(&first.project); let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 3;")); assert!(canonical.starts_with("arcadia 4;"));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap(); let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none()); assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project); assert_eq!(reopened.project, first.project);
@@ -223,7 +224,13 @@ fn migration_admission_counts_generated_objects_records_and_references() {
_ => 0, _ => 0,
}) })
}) })
.sum::<usize>(); .sum::<usize>()
+ decode(&old)
.migration
.unwrap()
.generated_interface_allocations
.len()
* 2;
for limits in [ for limits in [
ParseLimits { ParseLimits {
max_graph_objects: graph + 1, max_graph_objects: graph + 1,
@@ -244,7 +251,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
)); ));
} }
let accepted = ParseLimits { let accepted = ParseLimits {
max_graph_objects: graph + 2, max_graph_objects: migrated.elements.len() + migrated.relations.len(),
max_records: records, max_records: records,
max_step_references: reference_count, max_step_references: reference_count,
..ParseLimits::UNLIMITED ..ParseLimits::UNLIMITED
@@ -267,7 +274,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
assert!(error.contains("V2") && error.contains(&fe), "{error}"); assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json"); let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap(); let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 3); assert_eq!(result.project.schema_version, 4);
assert_eq!(result.migration.unwrap().from_schema, 1); assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project); assert_eq!(persistence::decode(v1).unwrap(), result.project);
} }
+6 -5
View File
@@ -1,6 +1,6 @@
use oa_core::{language, persistence, *}; use oa_core::{language, persistence, *};
const SOURCE: &str = r#"arcadia 3; const SOURCE: &str = r#"arcadia 4;
project typed_ports "Typed ports" { project typed_ports "Typed ports" {
sa_system producer "Producer" {} sa_system producer "Producer" {}
sa_actor consumer "Consumer" {} sa_actor consumer "Consumer" {}
@@ -9,7 +9,8 @@ project typed_ports "Typed ports" {
allocation sends "Produces" { source @producer; target @send; } allocation sends "Produces" { source @producer; target @send; }
allocation receives "Consumes" { source @consumer; target @receive; } allocation receives "Consumes" { source @consumer; target @receive; }
sa_exchange_item data "Data" {} sa_exchange_item data "Data" {}
sa_interface service "Service" { items [@data]; } sa_interface service "Service" {}
interface_item_allocation service_data "Service data" { source @service; target @data; }
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_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 input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; } sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
@@ -26,7 +27,7 @@ project typed_ports "Typed ports" {
"#; "#;
#[test] #[test]
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() { fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
let decoded = language::parse_with_report(SOURCE).unwrap(); let decoded = language::parse_with_report(SOURCE).unwrap();
assert!(decoded.migration.is_none()); assert!(decoded.migration.is_none());
let canonical = language::format(&decoded.project); let canonical = language::format(&decoded.project);
@@ -69,7 +70,7 @@ fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references
} }
#[test] #[test]
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() { fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
for (remove, expected) in [ for (remove, expected) in [
("source_port @output; ", "source_port"), ("source_port @output; ", "source_port"),
("target_port @input; ", "target_port"), ("target_port @input; ", "target_port"),
@@ -90,7 +91,7 @@ fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation()
} }
#[test] #[test]
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() { fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
let mixed = SOURCE.replace( let mixed = SOURCE.replace(
"parent @consumer; contract { Component { kind Standard; orientation Unset;", "parent @consumer; contract { Component { kind Standard; orientation Unset;",
"parent @consumer; contract { Component { kind Flow; orientation Unset;", "parent @consumer; contract { Component { kind Flow; orientation Unset;",
+228
View File
@@ -0,0 +1,228 @@
use oa_core::{language, persistence, *};
use serde_json::{Value, json};
const SOURCE3: &str = include_str!("fixtures/v3-data-contract.arcadia");
fn id(n: u128) -> Id {
Id::from_u128(n)
}
// Frozen JSON uses the same authored schema3 contract as the source fixture;
// this helper never serializes current defaults under version3.
fn original3() -> Value {
serde_json::from_str(include_str!("fixtures/v3-data-contract.json")).unwrap()
}
#[test]
fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() {
let old = original3();
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let source = language::parse_with_report(SOURCE3).unwrap();
assert_eq!(decoded.project, source.project);
assert_eq!(decoded.migration, source.migration);
let project = &decoded.project;
let report = decoded.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 4));
assert_eq!(project.identities().len(), 19);
assert!(report.preserved_ports.is_empty());
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.generated_interface_allocations.len(), 2);
for allocation in &report.generated_interface_allocations {
assert_eq!(
allocation.id,
Id::new_v5(
&id(1),
format!(
"v2-interface-item:{}:{}",
allocation.source, allocation.target
)
.as_bytes()
)
);
assert_eq!(allocation.target, id(4));
assert_eq!(allocation.order, 0);
assert!(matches!(
project.relations[&allocation.id].body,
RelationBody::InterfaceItemAllocation {
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
..
}
));
}
let now = serde_json::to_value(project).unwrap();
for port in [id(11), id(12)] {
assert_eq!(
now["elements"][port.to_string()],
old["elements"][port.to_string()]
);
}
assert!(matches!(
project.elements[&id(4)].shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Event,
supertype: None
})
));
assert_eq!(report.unresolved_message_values.len(), 1);
assert!(
validation::validate(project)
.iter()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
let canonical = language::format(project);
assert!(canonical.starts_with("arcadia 4;"));
let reopened = language::parse_with_report(&canonical).unwrap();
assert_eq!(&reopened.project, project);
assert!(reopened.migration.is_none());
assert_eq!(canonical, language::format(&reopened.project));
}
#[test]
fn v3_changed_payloads_reject_schema4_fields_and_new_constructor_names() {
let mut old = original3();
old["elements"][id(4).to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["mechanism"] =
json!("Flow");
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let mut old = original3();
old["elements"][id(5).to_string()]["body"]["Sa"]["Shared"]["DataType"]["definition"] =
json!({"Record":{"supertype":null}});
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let mut old = original3();
old["elements"][id(17).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]["body"]["Exchange"]
["values"][0]["path"] = json!([]);
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let source = SOURCE3.replace("kind Event;", "kind Information; mechanism Flow;");
assert!(language::parse(&source).is_err());
let source = SOURCE3.replace(
"functional_exchange position_exchange",
"interface_item_allocation position_exchange",
);
assert!(language::parse(&source).is_err());
}
#[test]
fn deterministic_allocation_id_collision_is_located_without_overwriting_any_object() {
let mut old = original3();
let collision = Id::new_v5(
&id(1),
format!("v2-interface-item:{}:{}", id(9), id(4)).as_bytes(),
);
old["artifact_links"][collision.to_string()] = json!({"id":collision,"alias":"retained_artifact","name":"Retain","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
let error = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap_err();
match error {
persistence::FileError::Model { issues, .. } => {
assert_eq!(issues[0].code, "identity_collision");
assert_eq!(issues[0].element, Some(id(9)));
assert_eq!(issues[0].field.as_deref(), Some("items"));
}
other => panic!("unexpected error {other}"),
}
}
#[test]
fn unique_legacy_nested_value_is_qualified_and_physical_nature_is_not_guessed() {
let source = SOURCE3.replace("kind Event;", "kind Material;").replace(
"type_ref { Named @coordinate; }; }\n }",
"type_ref String; }\n }",
);
let decoded = language::parse_with_report(&source).unwrap();
assert!(matches!(
decoded.project.elements[&id(4)].shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Unspecified,
..
})
));
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = decoded.project.elements[&id(17)].shared()
else {
panic!()
};
assert_eq!(values[0].path, vec![id(7)]);
assert_eq!(values[0].expression, "navigation.latitude");
}
#[test]
fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
let v2 =
persistence::decode_with_report(include_bytes!("fixtures/v2-coastal-rescue.json")).unwrap();
let text =
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(v2.project, text.project);
assert_eq!(v2.migration, text.migration);
assert_eq!(v2.migration.unwrap().to_schema, 4);
let v1 = language::parse_with_report(
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
)
.unwrap();
assert_eq!(v1.project.schema_version, 4);
assert_eq!(v1.migration.unwrap().from_schema, 1);
}
#[test]
fn allocation_alias_collisions_are_stable_and_duplicate_legacy_payload_fields_reject() {
let mut old = original3();
let first = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let alias = first.migration.unwrap().generated_interface_allocations[0]
.alias
.clone();
old["artifact_links"][id(100).to_string()] = json!({"id":id(100),"alias":alias,"name":"Existing alias","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
let bytes = serde_json::to_vec(&old).unwrap();
let migrated = persistence::decode_with_report(&bytes).unwrap();
let generated = &migrated
.migration
.as_ref()
.unwrap()
.generated_interface_allocations[0];
assert_ne!(generated.alias, alias);
assert!(generated.alias.starts_with(&alias));
assert_eq!(
migrated.project,
persistence::decode_with_report(&bytes).unwrap().project
);
let duplicate = include_str!("fixtures/v3-data-contract.json").replace(
"\"kind\": \"Event\"",
"\"kind\": \"Event\", \"kind\": \"Material\"",
);
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("Duplicate field 'kind'")
);
}
#[test]
fn recursive_legacy_record_paths_remain_ambiguous_instead_of_becoming_one_argument() {
let mut old = original3();
old["elements"][id(8).to_string()]["body"]["Sa"]["Shared"]["DataField"]["type_ref"] =
json!("String");
old["elements"][id(100).to_string()] = json!({"id":id(100),"alias":"next_coordinate","name":"Next coordinate","description":"","parent":id(5),"body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":id(5)},"minimum":0,"maximum":1,"unit":""}}}}});
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
assert_eq!(
decoded
.migration
.as_ref()
.unwrap()
.unresolved_message_values
.len(),
1
);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = decoded.project.elements[&id(17)].shared()
else {
panic!()
};
assert!(values[0].path.is_empty());
assert!(
validation::validate(&decoded.project)
.iter()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
}
+215
View File
@@ -0,0 +1,215 @@
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(n)
}
#[test]
fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_identity() {
let original = include_str!("fixtures/v3-data-contract.arcadia");
let header = original.find(" sa_function").unwrap();
let close = original.rfind('}').unwrap();
let entry = format!(
"{} // Contracts stay in their original module.\n include \"contracts.arcadia\";\n}}\n",
&original[..header]
);
let fragment = original[header..close].replace(
"items [@packet];",
"items [@ // interface contract decision\npacket];",
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry),
("contracts.arcadia".into(), fragment),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files.len(), 2);
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;"));
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
for text in [
"// Contracts stay in their original module.",
"// Reused record parameters retain their own identities.",
"// interface contract decision",
] {
assert!(
compiled
.bundle
.files
.values()
.any(|source| source.contains(text)),
"Lost {text}"
);
}
assert_eq!(compiled.index.lexical_owners[&id(6)], id(5));
assert_eq!(compiled.index.lexical_owners[&id(7)], id(4));
assert_eq!(compiled.index.lexical_owners[&id(8)], id(4));
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
let report = compiled.decoded.migration.as_ref().unwrap();
assert_eq!(report.from_schema, 3);
for allocation in &report.generated_interface_allocations {
assert_eq!(
compiled.index.declarations[&allocation.id].path,
"contracts.arcadia"
);
}
assert!(
compiled
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "message_value_ambiguous_path")
);
let second = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(second.bundle, compiled.bundle);
assert_eq!(second.decoded.project, compiled.decoded.project);
assert!(second.decoded.migration.is_none());
let mut renamed = compiled.decoded.project.clone();
renamed.elements.get_mut(&id(6)).unwrap().name = "Latitude degrees".into();
let updated = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
assert_eq!(updated.index.lexical_owners, compiled.index.lexical_owners);
assert!(updated.bundle.files["contracts.arcadia"].contains("Latitude degrees"));
assert!(
bundle.files["project.arcadia"].contains("arcadia 3;"),
"Migration does not mutate the caller's draft"
);
}
#[test]
fn schema4_typed_values_supertype_paths_and_protocols_roundtrip() {
let source = r#"arcadia 4; project data_contract "Data contract" {
sa_data_type bounded "Bounded" { definition { Integer { minimum -9223372036854775808; maximum 9223372036854775807; }; }; default { Integer -9223372036854775808; }; unit "m"; }
sa_data_type label_type "Label" { definition { String { minimum_length 1; maximum_length 4; }; }; default { String "航海"; }; }
sa_data_type base "Base" { definition { Record { supertype none; }; }; }
sa_data_type derived "Derived" { definition { Record { supertype @base; }; }; }
sa_exchange_item operation "Operation" { mechanism Operation;
sa_data_field value "Value" { type_ref { Named @bounded; }; direction In; storage Value; default [{ Integer 42; }]; }
}
sa_interface contract "Contract" { supertype none; }
interface_item_allocation member "Operation member" { source @contract; target @operation; transmission Synchronous; acquisition Accept; order 3; }
data_association association "Related records" { source @base; target @derived; source_role "parent"; target_role "children"; source_minimum 0; source_maximum 1; target_minimum 0; target_maximum none; source_navigable true; target_navigable true; }
}"#;
let parsed = language::parse(source).unwrap();
let formatted = language::format(&parsed);
assert!(formatted.contains("supertype @base"));
assert!(formatted.contains("transmission Synchronous"));
assert_eq!(language::parse(&formatted).unwrap(), parsed);
let compiled = compile_bundle(&format_bundle(&parsed), ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, parsed);
assert!(compiled.decoded.migration.is_none());
}
#[test]
fn omitted_ids_are_stabilized_before_allocation_identity_generation() {
let bundle=SourceBundle{entry:"project.arcadia".into(),files:BTreeMap::from([("project.arcadia".into(),"arcadia 3; project old_model \"Old\" { sa_exchange_item event_item \"Event\" { kind Event; } sa_interface contract \"Contract\" { items [@event_item]; } }".into())])};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let report = compiled.decoded.migration.as_ref().unwrap();
let allocation = &report.generated_interface_allocations[0];
assert_eq!(
allocation.id,
Id::new_v5(
&compiled.decoded.project.id,
format!(
"v2-interface-item:{}:{}",
allocation.source, allocation.target
)
.as_bytes()
)
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(reopened.bundle, compiled.bundle);
}
#[test]
fn inferring_a_legacy_path_keeps_rationale_inside_its_argument() {
let source = include_str!("fixtures/v3-data-contract.arcadia")
.replace(
"type_ref { Named @coordinate; }; }\n }",
"type_ref String; }\n }",
)
.replace(
"field @latitude; expression",
"field @latitude; // original argument rationale\nexpression",
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let source = &compiled.bundle.files["project.arcadia"];
let argument = &source[source.find("values [{").unwrap()..];
let argument = &argument[..argument.find('}').unwrap()];
assert!(
argument.contains("// original argument rationale"),
"{source}"
);
assert!(argument.contains("path [@origin]"), "{source}");
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
}
#[test]
fn qualified_value_reordering_and_path_reidentification_keep_each_arguments_comments() {
let mut project = language::parse(include_str!("fixtures/v3-data-contract.arcadia")).unwrap();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
else {
panic!()
};
*values = vec![
MessageValue {
path: vec![id(7)],
field: id(6),
expression: "origin value".into(),
},
MessageValue {
path: vec![id(8)],
field: id(6),
expression: "destination value".into(),
},
];
let mut bundle = format_bundle(&project);
for source in bundle.files.values_mut() {
*source = source
.replace(
"expression \"origin value\";",
"expression \"origin value\"; // origin argument rationale",
)
.replace(
"expression \"destination value\";",
"expression \"destination value\"; // destination argument rationale",
);
}
let current = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
else {
panic!()
};
values.reverse();
let renamed = Id::from_u128(100);
let mut identities: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
identities.insert(id(7), renamed);
reidentify(&mut project, id(7), renamed).unwrap();
project.elements.get_mut(&renamed).unwrap().alias = "revised_origin".into();
let result =
update_bundle_with_identity_map(&current, &project, &identities, ProjectLimits::default())
.unwrap();
let source = &result.bundle.files[&result.index.declarations[&id(17)].path];
let destination = source.find("// destination argument rationale").unwrap();
let origin = source.find("// origin argument rationale").unwrap();
assert!(
destination < origin,
"Distinct uses of the same terminal field must reorder as complete argument annotations: {source}"
);
assert!(source[origin..].contains("path [@revised_origin]"));
assert_eq!(result.decoded.project, project);
}
+6 -6
View File
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
fs::write( fs::write(
directory.path().join("project.arcadia"), directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n", "arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
) )
.unwrap(); .unwrap();
fs::create_dir(directory.path().join("parts")).unwrap(); fs::create_dir(directory.path().join("parts")).unwrap();
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() { fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
let directory = setup(); let directory = setup();
let entry = directory.path().join("project.arcadia"); let entry = directory.path().join("project.arcadia");
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap(); fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write( fs::write(
directory.path().join("parts/a.arcadia"), directory.path().join("parts/a.arcadia"),
"oa_activity a \"A\" { invalid", "oa_activity a \"A\" { invalid",
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
let overlays = BTreeMap::from([ let overlays = BTreeMap::from([
( (
"project.arcadia".into(), "project.arcadia".into(),
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(), "arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
), ),
( (
"parts/a.arcadia".into(), "parts/a.arcadia".into(),
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
files: BTreeMap::from([ files: BTreeMap::from([
( (
"project.arcadia".into(), "project.arcadia".into(),
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(), "arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
), ),
("child.arcadia".into(), "oa_activity a \"A\" {}".into()), ("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
]), ]),
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
.unwrap(); .unwrap();
fs::write( fs::write(
directory.path().join("project.arcadia"), directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }", "arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
) )
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
std::os::unix::fs::symlink(&destination, &link).unwrap(); std::os::unix::fs::symlink(&destination, &link).unwrap();
fs::write( fs::write(
directory.path().join("project.arcadia"), directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }", "arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
) )
.unwrap(); .unwrap();
let loaded = load_project( let loaded = load_project(
@@ -6,10 +6,10 @@ fn source() -> CompiledSourceProject {
&SourceBundle { &SourceBundle {
entry: "project.arcadia".into(), entry: "project.arcadia".into(),
files: BTreeMap::from([ files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()), ("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()), ("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()), ("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()), ("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
]), ]),
}, },
ProjectLimits::default(), ProjectLimits::default(),
@@ -91,8 +91,9 @@ fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origi
reidentify(&mut candidate, second, first).unwrap(); reidentify(&mut candidate, second, first).unwrap();
reidentify(&mut candidate, temporary, second).unwrap(); reidentify(&mut candidate, temporary, second).unwrap();
let contract = find(&candidate, "contract"); let contract = find(&candidate, "contract");
let Some(SharedBody::Interface { items }) = let Some(SharedBody::Event {
candidate.elements.get_mut(&contract).unwrap().shared_mut() body: EventBody::Signal { items },
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
else { else {
panic!("interface") panic!("interface")
}; };
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle { compile_bundle(&SourceBundle {
entry: "project.arcadia".into(), entry: "project.arcadia".into(),
files: BTreeMap::from([ files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()), ("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()), ("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
]), ]),
}, ProjectLimits::default()).unwrap() }, ProjectLimits::default()).unwrap()
+35 -33
View File
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
#[test] #[test]
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() { fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", "arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[ &[
( (
"work.arcadia", "work.arcadia",
@@ -95,7 +95,7 @@ diagram review "Review" {
#[test] #[test]
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() { fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
let mut input = bundle( let mut input = bundle(
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", "arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[ &[
( (
"work.arcadia", "work.arcadia",
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
fn typed() -> SourceBundle { fn typed() -> SourceBundle {
bundle( bundle(
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}", "arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
&[ &[
( (
"exchanges.arcadia", "exchanges.arcadia",
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
#[test] #[test]
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() { fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }", "arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
&[( &[(
"work.arcadia", "work.arcadia",
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n", "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
#[test] #[test]
fn source_errors_keep_exact_fragment_unicode_positions() { fn source_errors_keep_exact_fragment_unicode_positions() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }", "arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
&[( &[(
"child.arcadia", "child.arcadia",
"// Пример\nsa_function child \"航海\" { parent @missing; }", "// Пример\nsa_function child \"航海\" { parent @missing; }",
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
), ),
] { ] {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[("a.arcadia", left), ("b.arcadia", right)], &[("a.arcadia", left), ("b.arcadia", right)],
); );
let error = compile_bundle(&input, ProjectLimits::default()) let error = compile_bundle(&input, ProjectLimits::default())
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
assert_eq!(error.related[0].path, "a.arcadia"); assert_eq!(error.related[0].path, "a.arcadia");
} }
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }", "arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
&[("child.arcadia", "oa_activity p \"Collision\" {}")], &[("child.arcadia", "oa_activity p \"Collision\" {}")],
); );
let error = compile_bundle(&input, ProjectLimits::default()) let error = compile_bundle(&input, ProjectLimits::default())
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
#[test] #[test]
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() { fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
let mut input = bundle( let mut input = bundle(
"arcadia 3; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }", "arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
&[ &[
("sub/a.arcadia", "include \"../shared.arcadia\";"), ("sub/a.arcadia", "include \"../shared.arcadia\";"),
("sub/b.arcadia", "include \"../shared.arcadia\";"), ("sub/b.arcadia", "include \"../shared.arcadia\";"),
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
); );
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err()); assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" {}", "arcadia 4; project p \"P\" {}",
&[("A.arcadia", ""), ("a.arcadia", "")], &[("A.arcadia", ""), ("a.arcadia", "")],
); );
assert_eq!( assert_eq!(
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
#[test] #[test]
fn project_admission_is_aggregate_and_precedes_global_resolution() { fn project_admission_is_aggregate_and_precedes_global_resolution() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[ &[
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"), ("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
("b.arcadia", "oa_activity two \"Two\" {}"), ("b.arcadia", "oa_activity two \"Two\" {}"),
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
#[test] #[test]
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() { fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
let source = "arcadia 3; project p \"P\" { include \"missing.arcadia\"; }"; let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
assert!( assert!(
language::parse(source).unwrap_err()[0] language::parse(source).unwrap_err()[0]
.message .message
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
#[test] #[test]
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() { fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
let input = bundle( let input = bundle(
"arcadia 3;project p\"P\"{include \"child.arcadia\";}", "arcadia 4;project p\"P\"{include \"child.arcadia\";}",
&[( &[(
"child.arcadia", "child.arcadia",
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}", "oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
#[test] #[test]
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() { fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }", "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
&[("a.arcadia", "")], &[("a.arcadia", "")],
); );
assert_eq!( assert_eq!(
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
"project_limit" "project_limit"
); );
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[ &[
( (
"a.arcadia", "a.arcadia",
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
&source[span.start..span.end], &source[span.start..span.end],
"include \"../shared.arcadia\";" "include \"../shared.arcadia\";"
); );
let entry = "arcadia 3; project p \"P\" { include \"sub/work.arcadia\"; }"; let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
assert_eq!( assert_eq!(
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0] inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
.target, .target,
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
#[test] #[test]
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() { fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n"; let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n"; let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
let current = compile(&bundle(entry, &[("behavior.arcadia", source)])); let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
let mut project = current.decoded.project.clone(); let mut project = current.decoded.project.clone();
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
#[test] #[test]
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() { fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }", "arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
&[( &[(
"work.arcadia", "work.arcadia",
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n", "oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
#[test] #[test]
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() { fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }", "arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
&[( &[(
"work.arcadia", "work.arcadia",
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n", "oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
#[test] #[test]
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() { fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }", "arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
&[( &[(
"item.arcadia", "item.arcadia",
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n", "oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
#[test] #[test]
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() { fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }", "arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
&[( &[(
"event.arcadia", "event.arcadia",
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n", "oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
#[test] #[test]
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() { fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }", "arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
&[( &[(
"ports.arcadia", "ports.arcadia",
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n", "sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
@@ -1017,10 +1017,10 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
#[test] #[test]
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() { fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }", "arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
&[( &[(
"types.arcadia", "types.arcadia",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n", "sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
)], )],
); );
let current = compile(&input); let current = compile(&input);
@@ -1029,8 +1029,9 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
let b = id(&project, "b"); let b = id(&project, "b");
let service = id(&project, "service"); let service = id(&project, "service");
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into(); project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
if let Some(SharedBody::Interface { items }) = if let Some(SharedBody::Event {
project.elements.get_mut(&service).unwrap().shared_mut() body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
{ {
*items = vec![b, a]; *items = vec![b, a];
} else { } else {
@@ -1051,10 +1052,10 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
#[test] #[test]
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() { fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }", "arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
&[( &[(
"types.arcadia", "types.arcadia",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n", "sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
)], )],
); );
let current = compile(&input); let current = compile(&input);
@@ -1063,8 +1064,9 @@ fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations()
let b = id(&project, "b"); let b = id(&project, "b");
let service = id(&project, "service"); let service = id(&project, "service");
project.elements.remove(&a); project.elements.remove(&a);
if let Some(SharedBody::Interface { items }) = if let Some(SharedBody::Event {
project.elements.get_mut(&service).unwrap().shared_mut() body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
{ {
*items = vec![b]; *items = vec![b];
} else { } else {
@@ -1102,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
}; }; } }; }; }
"#; "#;
let current = compile(&bundle( let current = compile(&bundle(
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }", "arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
&[("conversation.arcadia", source)], &[("conversation.arcadia", source)],
)); ));
let mut project = current.decoded.project.clone(); let mut project = current.decoded.project.clone();
@@ -1151,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
#[test] #[test]
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() { fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
let input = bundle( let input = bundle(
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }", "arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
&[( &[(
"types.arcadia", "types.arcadia",
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n", "oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
@@ -1160,7 +1162,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
let empty = compile(&input); let empty = compile(&input);
let mut project = empty.decoded.project.clone(); let mut project = empty.decoded.project.clone();
let values = id(&project, "values"); let values = id(&project, "values");
if let Some(SharedBody::DataType { definition }) = if let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&values).unwrap().shared_mut() project.elements.get_mut(&values).unwrap().shared_mut()
{ {
*definition = DataTypeDefinition::Enumeration { *definition = DataTypeDefinition::Enumeration {
@@ -1185,7 +1187,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"), .replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
); );
let annotated = compile(&annotated); let annotated = compile(&annotated);
if let Some(SharedBody::DataType { definition }) = if let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&values).unwrap().shared_mut() project.elements.get_mut(&values).unwrap().shared_mut()
{ {
*definition = DataTypeDefinition::Enumeration { *definition = DataTypeDefinition::Enumeration {