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
+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 {