Preserve source identity ownership across prepared model commands

This commit is contained in:
2026-09-05 22:25:51 +03:00
parent f6d04ee4b3
commit 86bf36bf4f
5 changed files with 657 additions and 23 deletions
+101 -13
View File
@@ -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<Id>,
/// 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<Id, Id>,
}
/// Track actual identity operations, not aliases or structural similarity.
/// The reverse map composes renames without scanning every original object.
struct IdentityProvenance {
original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>,
}
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<Id, Id> {
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<PreparedCommand, ModelError> {
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<Project, ModelError> {
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<Id>,
provenance: &mut IdentityProvenance,
) -> Result<Option<Id>, 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) => {
+83 -10
View File
@@ -261,6 +261,16 @@ pub fn inspect_includes(
type Edits = BTreeMap<LogicalPath, Vec<(Span, String)>>;
/// 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<SourceDiagnostic> {
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<CompiledSourceProject, Vec<SourceDiagnostic>> {
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<Id, Id>,
limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
// 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 = &current.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::<BTreeSet<_>>().len() != identities.len()
|| identities.iter().any(|(before, after)| {
!old.contains(*before)
|| !project.contains(*after)
|| category(old, *before) != category(project, *after)
})
{
return Err(vec![problem(
&current.bundle,
&current.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 = &current.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<String, Id>,
new_aliases: &'a BTreeMap<String, Id>,
old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>,
}
impl SyntaxUpdate<'_> {
fn header(
@@ -1420,7 +1493,7 @@ impl SyntaxUpdate<'_> {
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, Id>,
aliases: &BTreeMap<String, String>,
) -> Option<String> {
use language::EditKind;
match &value.kind {
+296
View File
@@ -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<Id> {
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);
}
@@ -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<Id, Id> {
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(
&current,
&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(
&current,
&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 = &current.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(
&current,
candidate,
&identities,
ProjectLimits::default(),
)
.unwrap_err();
assert_eq!(errors[0].diagnostic.code, "invalid_identity_map");
}
}
+34
View File
@@ -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(