diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index be06131e..ddb3a41e 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -1,6 +1,6 @@ use crate::*; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use thiserror::Error; #[derive(Debug, Clone, Error, Serialize, Deserialize)] @@ -93,6 +93,65 @@ pub struct Session { pub struct PreparedCommand { pub project: Project, pub selected: Option, + /// Complete correspondence from surviving original identities to candidate + /// identities, including unchanged objects. Newly created objects have no + /// origin. Replacement establishes correspondence by common original UUIDs. + pub identity_map: BTreeMap, +} + +/// Track actual identity operations, not aliases or structural similarity. +/// The reverse map composes renames without scanning every original object. +struct IdentityProvenance { + original: BTreeSet, + current_to_original: BTreeMap, +} + +impl IdentityProvenance { + fn new(project: &Project) -> Self { + let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect(); + let current_to_original = original.iter().map(|id| (*id, *id)).collect(); + Self { + original, + current_to_original, + } + } + + fn replace(&mut self, project: &Project) { + let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect(); + self.current_to_original = self + .original + .intersection(&present) + .map(|id| (*id, *id)) + .collect(); + } + + fn retain_present(&mut self, project: &Project) { + if !self.current_to_original.is_empty() { + let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect(); + self.current_to_original + .retain(|id, _| present.contains(id)); + } + } + + fn into_map(self) -> BTreeMap { + self.current_to_original + .into_iter() + .map(|(current, original)| (original, current)) + .collect() + } +} + +fn reidentify_tracked( + project: &mut Project, + old: Id, + new: Id, + provenance: &mut IdentityProvenance, +) -> Result<(), ModelError> { + reidentify(project, old, new)?; + if let Some(original) = provenance.current_to_original.remove(&old) { + provenance.current_to_original.insert(new, original); + } + Ok(()) } pub fn prepare_command( @@ -101,12 +160,21 @@ pub fn prepare_command( ) -> Result { let mut candidate = project.clone(); let mut pending_deletions = BTreeSet::new(); - let selected = apply_command(&mut candidate, command, 0, &mut pending_deletions)?; + let mut provenance = IdentityProvenance::new(project); + let selected = apply_command( + &mut candidate, + command, + 0, + &mut pending_deletions, + &mut provenance, + )?; crate::references::finish_deletions(&mut candidate, &pending_deletions); + provenance.retain_present(&candidate); validate_candidate(&candidate)?; Ok(PreparedCommand { project: candidate, selected, + identity_map: provenance.into_map(), }) } @@ -171,6 +239,7 @@ impl Session { let PreparedCommand { project: candidate, selected, + .. } = prepare_command(&self.project, &command)?; if candidate != self.project { self.past @@ -197,8 +266,15 @@ pub fn validate_candidate(p: &Project) -> Result<(), ModelError> { pub fn execute_batch(project: &Project, commands: &[Command]) -> Result { let mut candidate = project.clone(); let mut pending_deletions = BTreeSet::new(); + let mut provenance = IdentityProvenance::new(project); for command in commands { - apply_command(&mut candidate, command, 0, &mut pending_deletions)?; + apply_command( + &mut candidate, + command, + 0, + &mut pending_deletions, + &mut provenance, + )?; } crate::references::finish_deletions(&mut candidate, &pending_deletions); validate_candidate(&candidate)?; @@ -266,6 +342,7 @@ fn apply_command( c: &Command, depth: usize, pending_deletions: &mut BTreeSet, + provenance: &mut IdentityProvenance, ) -> Result, ModelError> { if depth > 64 { return Err(ModelError( @@ -276,12 +353,13 @@ fn apply_command( Command::Replace(value) => { *p = value.clone(); pending_deletions.clear(); + provenance.replace(p); None } Command::Batch(commands) => { let mut selected = None; for command in commands { - let id = apply_command(p, command, depth + 1, pending_deletions)?; + let id = apply_command(p, command, depth + 1, pending_deletions, provenance)?; if id.is_some() { selected = id; } @@ -342,7 +420,7 @@ fn apply_command( let mut e = value.clone(); e.id = *old; p.elements.insert(*old, e); - reidentify(p, *old, value.id)?; + reidentify_tracked(p, *old, value.id, provenance)?; if ownership_change { attach(p, value.id); } @@ -360,7 +438,7 @@ fn apply_command( let mut v = value.clone(); v.id = *old; p.relations.insert(*old, v); - reidentify(p, *old, value.id)?; + reidentify_tracked(p, *old, value.id, provenance)?; if pending_deletions.remove(old) { pending_deletions.insert(value.id); } @@ -378,7 +456,9 @@ fn apply_command( let mut v = value.clone(); v.id = *old; p.diagrams.insert(*old, v); - reidentify(p, *old, value.id)?; + // Replacing a diagram can remove embedded placement identities. + provenance.retain_present(p); + reidentify_tracked(p, *old, value.id, provenance)?; Some(value.id) } Command::CreateArtifact(value) => { @@ -393,7 +473,7 @@ fn apply_command( let mut v = value.clone(); v.id = *old; p.artifact_links.insert(*old, v); - reidentify(p, *old, value.id)?; + reidentify_tracked(p, *old, value.id, provenance)?; Some(value.id) } Command::CreateTrace(value) => { @@ -408,7 +488,7 @@ fn apply_command( let mut v = value.clone(); v.id = *old; p.traces.insert(*old, v); - reidentify(p, *old, value.id)?; + reidentify_tracked(p, *old, value.id, provenance)?; Some(value.id) } Command::CreateNode { diagram, node } => { @@ -432,7 +512,7 @@ fn apply_command( let mut n = node.clone(); n.id = *original; d.nodes.insert(*original, n); - reidentify(p, *original, node.id)?; + reidentify_tracked(p, *original, node.id, provenance)?; Some(node.id) } Command::RelocateNode { node, diagram } => { @@ -467,7 +547,7 @@ fn apply_command( name, description, } => { - reidentify(p, p.id, *id)?; + reidentify_tracked(p, p.id, *id, provenance)?; p.alias = alias.clone(); p.name = name.clone(); p.description = description.clone(); @@ -483,7 +563,7 @@ fn apply_command( Some(*element) } Command::Reidentify(old, new) => { - reidentify(p, *old, *new)?; + reidentify_tracked(p, *old, *new, provenance)?; if pending_deletions.remove(old) { pending_deletions.insert(*new); } @@ -518,7 +598,13 @@ fn apply_command( items: vec![], }, )?; - return apply_command(p, &Command::Batch(commands), depth + 1, pending_deletions); + return apply_command( + p, + &Command::Batch(commands), + depth + 1, + pending_deletions, + provenance, + ); } Some(p.relate(*kind, name, *source, *target)) } @@ -530,6 +616,7 @@ fn apply_command( return Err(missing()); } crate::references::delete_cascade_deferred(p, *id, pending_deletions); + provenance.retain_present(p); None } Command::AddStep(owner, exchange) => { @@ -569,6 +656,7 @@ fn apply_command( .and_then(|s| s.get(*index)) .ok_or_else(|| ModelError("Step index is out of range.".into()))?; crate::references::delete_cascade_deferred(p, id, pending_deletions); + provenance.retain_present(p); Some(*owner) } Command::MoveStep(owner, from, to) => { diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 62262909..577e3d59 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -261,6 +261,16 @@ pub fn inspect_includes( type Edits = BTreeMap>; +/// Diagnose the exact editable bytes, without inserting omitted UUIDs. Locations +/// refer to the supplied bundle, including when successful compilation would +/// otherwise stabilize identities before returning its index and diagnostics. +pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec { + match compile_once(bundle, limits) { + Ok((compiled, _)) => compiled.diagnostics, + Err(diagnostics) => diagnostics, + } +} + /// Compile all reachable files as one source3 model and stabilize omitted UUIDs. /// Missing/unreachable files and every admission or engineering error leave the /// input untouched. V1/V2 single-file migration remains in `language::parse`. @@ -727,6 +737,28 @@ pub fn update_bundle( current: &CompiledSourceProject, project: &Project, limits: ProjectLimits, +) -> Result> { + let identities = current + .decoded + .project + .identities() + .into_iter() + .filter(|(id, _)| project.contains(*id)) + .map(|(id, _)| (id, id)) + .collect(); + update_bundle_with_identity_map(current, project, &identities, limits) +} + +/// Preserve declaration and reference-comment ownership through identity edits. +/// `identities` maps every surviving original identity to its candidate identity; +/// new records have no entry. Use the map from `prepare_command`, including for +/// nested batches and identity swaps. An arbitrary model replacement without +/// command provenance can use `update_bundle`'s ordinary same-UUID matching. +pub fn update_bundle_with_identity_map( + current: &CompiledSourceProject, + project: &Project, + identities: &BTreeMap, + limits: ProjectLimits, ) -> Result> { // Public document/index fields are convenient for callers, but cannot become // an unchecked editing authority if a caller has changed either of them. @@ -760,26 +792,68 @@ pub fn update_bundle( )] })?; let old = ¤t.decoded.project; + // A supplied map is editing intent, never permission to create duplicate + // records, refer to absent objects, or move syntax between record classes. + let category = |model: &Project, id: Id| { + if model.id == id { + 0 + } else if model.elements.contains_key(&id) { + 1 + } else if model.relations.contains_key(&id) { + 2 + } else if model.diagrams.contains_key(&id) { + 3 + } else if model.artifact_links.contains_key(&id) { + 4 + } else if model.traces.contains_key(&id) { + 5 + } else { + 6 // Node placements; existence is checked separately. + } + }; + if identities.values().collect::>().len() != identities.len() + || identities.iter().any(|(before, after)| { + !old.contains(*before) + || !project.contains(*after) + || category(old, *before) != category(project, *after) + }) + { + return Err(vec![problem( + ¤t.bundle, + ¤t.bundle.entry, + Span::default(), + "invalid_identity_map", + "Source identity correspondence must map existing records one-to-one within their record class.", + )]); + } + let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect(); let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect(); let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect(); let old_aliases: BTreeMap<_, _> = old .identities() .into_iter() - .map(|(id, alias)| (alias.to_owned(), id)) + .map(|(id, alias)| (alias.to_owned(), format!("original:{id}"))) .collect(); let new_aliases: BTreeMap<_, _> = project .identities() .into_iter() - .map(|(id, alias)| (alias.to_owned(), id)) + .map(|(id, alias)| { + let identity = origins.get(&id).map_or_else( + || format!("new:{id}"), + |origin| format!("original:{origin}"), + ); + (alias.to_owned(), identity) + }) .collect(); let mut bundle = current.bundle.clone(); let mut edits: Edits = BTreeMap::new(); for (id, old_text) in &before { let location = ¤t.index.declarations[id]; - if after.get(id) != Some(old_text) { + let new_text = identities.get(id).and_then(|id| after.get(id)); + if new_text != Some(old_text) { let source = &bundle.files[&location.path][location.span.start..location.span.end]; let mut local = Vec::new(); - if let Some(new_text) = after.get(id) { + if let Some(new_text) = new_text { let parse = |text| { language::edit_declaration(text).map_err(|error| { vec![problem( @@ -906,8 +980,7 @@ pub fn update_bundle( .index .declarations .iter() - .filter(|(id, _)| project.contains(**id)) - .map(|(id, location)| (*id, location.path.clone())) + .filter_map(|(id, location)| identities.get(id).map(|new| (*new, location.path.clone()))) .collect(); let mut chosen_defaults = BTreeMap::new(); let mut used_paths: BTreeSet<_> = bundle @@ -919,7 +992,7 @@ pub fn update_bundle( let aliases: BTreeMap<_, _> = project.identities().into_iter().collect(); let mut new_ids: Vec<_> = after .keys() - .filter(|id| !before.contains_key(*id)) + .filter(|id| !origins.contains_key(*id)) .copied() .collect(); new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases)); @@ -1089,8 +1162,8 @@ struct SyntaxUpdate<'a> { source: &'a str, before: &'a str, after: &'a str, - old_aliases: &'a BTreeMap, - new_aliases: &'a BTreeMap, + old_aliases: &'a BTreeMap, + new_aliases: &'a BTreeMap, } impl SyntaxUpdate<'_> { fn header( @@ -1420,7 +1493,7 @@ impl SyntaxUpdate<'_> { fn item_key( value: &language::EditValue, source: &str, - aliases: &BTreeMap, + aliases: &BTreeMap, ) -> Option { use language::EditKind; match &value.kind { diff --git a/crates/oa-core/tests/prepared_commands.rs b/crates/oa-core/tests/prepared_commands.rs new file mode 100644 index 00000000..e4d372ea --- /dev/null +++ b/crates/oa-core/tests/prepared_commands.rs @@ -0,0 +1,296 @@ +use oa_core::*; +use std::collections::{BTreeMap, BTreeSet}; + +fn pair() -> (Project, Id, Id) { + let mut project = Project::empty(); + let first = project + .add_element(Kind::Activity, Layer::Oa, "First", None) + .unwrap(); + let second = project + .add_element(Kind::Activity, Layer::Oa, "Second", None) + .unwrap(); + (project, first, second) +} + +fn identities(project: &Project) -> BTreeSet { + project.identities().into_iter().map(|(id, _)| id).collect() +} + +fn valid_map(original: &Project, prepared: &PreparedCommand) { + let origins = identities(original); + let present = identities(&prepared.project); + let targets: BTreeSet<_> = prepared.identity_map.values().copied().collect(); + assert_eq!( + targets.len(), + prepared.identity_map.len(), + "correspondence is injective" + ); + assert!(targets.is_subset(&present)); + assert!(prepared.identity_map.keys().all(|id| origins.contains(id))); +} + +#[test] +fn explicit_chains_and_nested_swaps_compose_original_identity() { + let (project, first, second) = pair(); + let temporary = Id::new_v4(); + let final_id = Id::new_v4(); + let chained = prepare_command( + &project, + &Command::Batch(vec![ + Command::Reidentify(first, temporary), + Command::Batch(vec![Command::Reidentify(temporary, final_id)]), + ]), + ) + .unwrap(); + assert_eq!( + chained.identity_map, + BTreeMap::from([ + (project.id, project.id), + (first, final_id), + (second, second) + ]) + ); + assert_eq!(chained.selected, Some(final_id)); + valid_map(&project, &chained); + + let swapped = prepare_command( + &project, + &Command::Batch(vec![ + Command::Reidentify(first, temporary), + Command::Batch(vec![ + Command::Reidentify(second, first), + Command::Reidentify(temporary, second), + ]), + ]), + ) + .unwrap(); + assert_eq!( + swapped.identity_map, + BTreeMap::from([(project.id, project.id), (first, second), (second, first)]) + ); + assert_eq!(swapped.project.elements[&second].alias, "first"); + assert_eq!(swapped.project.elements[&first].alias, "second"); + valid_map(&project, &swapped); +} + +#[test] +fn all_edit_commands_record_their_implicit_identity_change() { + let project = Project::demo(); + let new = Id::new_v4(); + let mut element = project.elements.values().next().unwrap().clone(); + let element_id = element.id; + element.id = new; + element.alias = "edited_identity_and_alias".into(); + let mut relation = project.relations.values().next().unwrap().clone(); + let relation_id = relation.id; + relation.id = new; + let mut diagram = project.diagrams.values().next().unwrap().clone(); + let diagram_id = diagram.id; + let mut node = diagram.nodes.values().next().unwrap().clone(); + let node_id = node.id; + node.id = new; + diagram.id = new; + let mut artifact = project.artifact_links.values().next().unwrap().clone(); + let artifact_id = artifact.id; + artifact.id = new; + let mut trace = project.traces.values().next().unwrap().clone(); + let trace_id = trace.id; + trace.id = new; + let cases = [ + (element_id, Command::EditElement(element_id, element)), + (relation_id, Command::EditRelation(relation_id, relation)), + (diagram_id, Command::EditDiagram(diagram_id, diagram)), + ( + node_id, + Command::EditNode { + diagram: diagram_id, + original: node_id, + node, + }, + ), + (artifact_id, Command::EditArtifact(artifact_id, artifact)), + (trace_id, Command::EditTrace(trace_id, trace)), + ( + project.id, + Command::EditProject { + id: new, + alias: project.alias.clone(), + name: project.name.clone(), + description: project.description.clone(), + }, + ), + ]; + for (old, command) in cases { + let prepared = prepare_command(&project, &command).unwrap(); + let expected: BTreeMap<_, _> = identities(&project) + .into_iter() + .map(|id| (id, if id == old { new } else { id })) + .collect(); + assert_eq!(prepared.identity_map, expected, "{command:?}"); + valid_map(&project, &prepared); + } +} + +#[test] +fn deletion_and_cascade_discard_origins_before_uuid_reuse() { + let (mut project, first, second) = pair(); + let child = project + .add_element(Kind::Activity, Layer::Oa, "Nested work", Some(first)) + .unwrap(); + let mut fresh = project.elements[&child].clone(); + fresh.parent = None; + fresh.alias = "replacement_work".into(); + let prepared = prepare_command( + &project, + &Command::Batch(vec![Command::Delete(first), Command::CreateElement(fresh)]), + ) + .unwrap(); + assert!(prepared.project.contains(child)); + assert_eq!( + prepared.identity_map, + BTreeMap::from([(project.id, project.id), (second, second)]) + ); + valid_map(&project, &prepared); +} + +#[test] +fn embedded_diagram_replacement_cannot_revive_deleted_node_origin() { + let mut project = Project::empty(); + let element = project.add(Kind::Activity, "Work", 0., 0.); + let mut diagram = project.diagrams.values().next().unwrap().clone(); + let node = diagram.nodes.values().next().unwrap().clone(); + diagram.nodes.clear(); + let prepared = prepare_command( + &project, + &Command::Batch(vec![ + Command::EditDiagram(diagram.id, diagram.clone()), + Command::CreateNode { + diagram: diagram.id, + node: node.clone(), + }, + ]), + ) + .unwrap(); + assert_eq!(prepared.project, project); + assert_eq!( + prepared.identity_map, + BTreeMap::from([ + (project.id, project.id), + (element, element), + (diagram.id, diagram.id) + ]) + ); + assert!(!prepared.identity_map.contains_key(&node.id)); + valid_map(&project, &prepared); +} + +#[test] +fn fresh_creation_has_no_origin_even_when_subsequently_reidentified() { + let (project, _, _) = pair(); + let mut fresh = project + .make_element(Kind::Activity, Layer::Oa, "New work", None) + .unwrap(); + let created = fresh.id; + let renamed = Id::new_v4(); + fresh.alias = "fresh_identity".into(); + let prepared = prepare_command( + &project, + &Command::Batch(vec![ + Command::CreateElement(fresh), + Command::Reidentify(created, renamed), + ]), + ) + .unwrap(); + assert_eq!(prepared.selected, Some(renamed)); + assert!(prepared.project.contains(renamed)); + assert_eq!( + prepared.identity_map, + identities(&project) + .into_iter() + .map(|id| (id, id)) + .collect() + ); + valid_map(&project, &prepared); + let generated = prepare_command( + &project, + &Command::AddElement { + kind: Kind::Activity, + layer: Layer::Oa, + name: "Generated once".into(), + parent: None, + }, + ) + .unwrap(); + let selected = generated.selected.unwrap(); + assert!(generated.project.contains(selected)); + assert!(!generated.identity_map.values().any(|id| *id == selected)); + assert_eq!(generated.project.elements.len(), project.elements.len() + 1); +} + +#[test] +fn replacement_resets_correspondence_against_the_original_input() { + let (project, first, second) = pair(); + let temporary = Id::new_v4(); + let mut replacement = project.clone(); + replacement.elements.remove(&second); + let new = replacement + .add_element(Kind::Activity, Layer::Oa, "Replacement addition", None) + .unwrap(); + let final_id = Id::new_v4(); + let prepared = prepare_command( + &project, + &Command::Batch(vec![ + Command::Reidentify(first, temporary), + Command::Delete(second), + Command::Replace(replacement), + Command::Reidentify(first, final_id), + ]), + ) + .unwrap(); + assert_eq!( + prepared.identity_map, + BTreeMap::from([(project.id, project.id), (first, final_id)]) + ); + assert!(!prepared.identity_map.values().any(|id| *id == new)); + valid_map(&project, &prepared); + let restored = prepare_command( + &project, + &Command::Batch(vec![ + Command::Delete(first), + Command::Replace(project.clone()), + ]), + ) + .unwrap(); + assert_eq!( + restored.identity_map, + identities(&project) + .into_iter() + .map(|id| (id, id)) + .collect() + ); +} + +#[test] +fn failed_preparation_leaves_input_unchanged_after_intermediate_identity_work() { + let (project, first, second) = pair(); + let before = project.clone(); + let temporary = Id::new_v4(); + let failed = prepare_command( + &project, + &Command::Batch(vec![ + Command::Reidentify(first, temporary), + Command::Reidentify(second, temporary), + ]), + ); + assert!(failed.is_err()); + assert_eq!(project, before); + let failed_validation = prepare_command( + &project, + &Command::Batch(vec![ + Command::Reidentify(first, temporary), + Command::SetAlias(temporary, "second".into()), + ]), + ); + assert!(failed_validation.is_err()); + assert_eq!(project, before); +} diff --git a/crates/oa-core/tests/source_identity_updates.rs b/crates/oa-core/tests/source_identity_updates.rs new file mode 100644 index 00000000..4f71906c --- /dev/null +++ b/crates/oa-core/tests/source_identity_updates.rs @@ -0,0 +1,143 @@ +use oa_core::{source_project::*, *}; +use std::collections::BTreeMap; + +fn source() -> CompiledSourceProject { + compile_bundle( + &SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), "arcadia 3; 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/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()), + ]), + }, + ProjectLimits::default(), + ).unwrap() +} + +fn find(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} + +fn unchanged(project: &Project) -> BTreeMap { + project + .identities() + .into_iter() + .map(|(id, _)| (id, id)) + .collect() +} + +#[test] +fn identity_and_alias_edit_keep_authored_file_and_reference_rationale() { + let current = source(); + let mut candidate = current.decoded.project.clone(); + let first = find(&candidate, "first"); + let changed = Id::new_v4(); + let mut identities = unchanged(&candidate); + identities.insert(first, changed); + reidentify(&mut candidate, first, changed).unwrap(); + candidate.elements.get_mut(&changed).unwrap().alias = "revised_first".into(); + let result = update_bundle_with_identity_map( + ¤t, + &candidate, + &identities, + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(result.decoded.project, candidate); + assert_eq!( + result.index.declarations[&changed].path, + "team/first.arcadia" + ); + assert_eq!(result.bundle.files.len(), current.bundle.files.len()); + assert_eq!( + result.bundle.files["team/second.arcadia"], + current.bundle.files["team/second.arcadia"] + ); + assert_eq!( + result.bundle.files["team/first.arcadia"], + current.bundle.files["team/first.arcadia"] + .replace(&first.to_string(), &changed.to_string()) + .replace("sa_exchange_item first ", "sa_exchange_item revised_first ") + ); + assert!( + result.bundle.files["contracts.arcadia"].contains("@revised_first // first item rationale") + ); + assert_eq!( + update_bundle(&result, &candidate, ProjectLimits::default()) + .unwrap() + .bundle, + result.bundle + ); +} + +#[test] +fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origin() { + let current = source(); + let mut candidate = current.decoded.project.clone(); + let first = find(&candidate, "first"); + let second = find(&candidate, "second"); + let temporary = Id::new_v4(); + let mut identities = unchanged(&candidate); + identities.insert(first, second); + identities.insert(second, first); + reidentify(&mut candidate, first, temporary).unwrap(); + reidentify(&mut candidate, second, first).unwrap(); + reidentify(&mut candidate, temporary, second).unwrap(); + let contract = find(&candidate, "contract"); + let Some(SharedBody::Interface { items }) = + candidate.elements.get_mut(&contract).unwrap().shared_mut() + else { + panic!("interface") + }; + items.reverse(); + let result = update_bundle_with_identity_map( + ¤t, + &candidate, + &identities, + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(result.decoded.project, candidate); + assert_eq!( + result.index.declarations[&second].path, + "team/first.arcadia" + ); + assert_eq!( + result.index.declarations[&first].path, + "team/second.arcadia" + ); + let text = &result.bundle.files["contracts.arcadia"]; + assert!(text.contains("@first // first item rationale"), "{text}"); + assert!(text.contains("@second, // second item rationale"), "{text}"); + assert!( + text.find("second item rationale").unwrap() < text.find("first item rationale").unwrap(), + "{text}" + ); +} + +#[test] +fn provenance_cannot_merge_records_or_cross_record_classes() { + let current = source(); + let candidate = ¤t.decoded.project; + let first = find(candidate, "first"); + let second = find(candidate, "second"); + for destination in [second, candidate.id, Id::new_v4()] { + let mut identities = unchanged(candidate); + identities.insert(first, destination); + let errors = update_bundle_with_identity_map( + ¤t, + candidate, + &identities, + ProjectLimits::default(), + ) + .unwrap_err(); + assert_eq!(errors[0].diagnostic.code, "invalid_identity_map"); + } +} diff --git a/crates/oa-core/tests/source_project.rs b/crates/oa-core/tests/source_project.rs index 129a6f22..b00546b5 100644 --- a/crates/oa-core/tests/source_project.rs +++ b/crates/oa-core/tests/source_project.rs @@ -191,6 +191,40 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() { assert_eq!(warning.diagnostic.line, 2); } +#[test] +fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() { + let input = bundle( + "arcadia 3; project p \"P\" { include \"work.arcadia\"; }", + &[( + "work.arcadia", + "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n", + )], + ); + let before = input.clone(); + let raw = &input.files["work.arcadia"]; + let warnings = lint_bundle(&input, ProjectLimits::default()); + let second = warnings + .iter() + .find(|d| { + d.path == "work.arcadia" + && d.diagnostic.code == "unallocated_leaf" + && d.diagnostic.line == 2 + }) + .unwrap(); + assert_eq!( + second.diagnostic.span.start, + raw.find("sa_function second").unwrap() + ); + assert!(raw[second.diagnostic.span.start..second.diagnostic.span.end].contains("second")); + assert_eq!(input, before); + let compiled = compile(&input); + assert_ne!(compiled.bundle, input); + assert!(compiled.diagnostics.iter().any(|d| d.path == "work.arcadia" + && d.diagnostic.code == "unallocated_leaf" + && d.diagnostic.line == 2 + && d.diagnostic.span.start > second.diagnostic.span.start)); +} + #[test] fn source_errors_keep_exact_fragment_unicode_positions() { let input = bundle(