Plan complete mixed source renames against whole target models

This commit is contained in:
2026-09-24 16:16:45 -07:00
parent ee500908e3
commit 9388fcf2f0
4 changed files with 1464 additions and 0 deletions
@@ -429,3 +429,5 @@ fn word(unit: &FileSyntax<'_>, at: Span, expected: &str) -> Result<(), TypedErro
#[cfg(test)]
mod tests;
pub(in crate::language::analysis_source) mod rename;
@@ -0,0 +1,491 @@
//! Complete-target rename planning only; no output emission or admission.
use super::*;
use crate::source_project::SourceBundle;
use lower::Target;
pub(in crate::language::analysis_source) struct RenamePlan<'s, 't> {
source: AuthoredMixedProject<'s>,
desired: &'t Project,
names: BTreeMap<Target, &'t str>,
edits: BTreeMap<OrdinaryKey, Target>,
}
impl<'s> AuthoredMixedProject<'s> {
pub fn plan_rename<'t>(mut self, desired: &'t Project) -> Result<RenamePlan<'s, 't>, Failure> {
let mut failure_file = None;
let result = (|| {
let resolved = &mut self.indexed.resolved;
let e = resolved.expressions.len();
resolved.storage.reserve(
&resolved.syntax.budget,
Slots::entries(e),
Span::default(),
)?;
let equal = same_project(&resolved.project, desired);
resolved.storage.release(Slots::entries(e));
if !equal {
return Err(error(
Span::default(),
"Rename target changes non-name project semantics.",
));
}
let mut names = BTreeMap::new();
let mut bytes = 0;
visit_names(desired, &mut |target, name| {
let old = self.indexed.data.names.get(&target).ok_or_else(|| {
error(
Span::default(),
"Rename target is absent from the original catalog.",
)
})?;
if old != name {
bytes = add(bytes, name.len(), Span::default())?;
self.indexed
.resolved
.syntax
.budget
.index_bytes(bytes, Span::default())?;
if !crate::model::valid_alias(name) {
return Err(error(Span::default(), "Invalid rename alias."));
}
self.indexed.resolved.storage.reserve(
&self.indexed.resolved.syntax.budget,
Slots::entries(1),
Span::default(),
)?;
if names.insert(target, name).is_some() {
return Err(error(Span::default(), "Duplicate rename target."));
}
}
Ok(())
})?;
if !names.is_empty() {
let mut namespace = BTreeSet::new();
for (target, old) in &self.indexed.data.names {
let scope = match target {
Target::Global(_) => None,
Target::Parameter { calculation, .. } => Some(*calculation),
};
let name = names.get(target).copied().unwrap_or(old);
self.indexed.resolved.storage.reserve(
&self.indexed.resolved.syntax.budget,
Slots::entries(1),
Span::default(),
)?;
if !namespace.insert((scope, name)) {
return Err(error(
Span::default(),
"Rename aliases collide in their final namespace.",
));
}
}
let q = namespace.len();
drop(namespace);
self.indexed.resolved.storage.release(Slots::entries(q));
}
let mut edits = BTreeMap::new();
for (key, location) in &self.indexed.data.occurrences {
if names.contains_key(&key.target) {
failure_file = Some(location.file);
insert_edit(
&mut self.indexed.resolved.storage,
&self.indexed.resolved.syntax.budget,
&mut edits,
(location.file, location.span.start, location.span.end),
key.target,
)?;
}
}
for (&key, occurrence) in &self.ordinary {
let target = Target::Global(occurrence.target);
if names.contains_key(&target) {
failure_file = Some(key.0);
insert_edit(
&mut self.indexed.resolved.storage,
&self.indexed.resolved.syntax.budget,
&mut edits,
key,
target,
)?;
}
}
Ok((names, edits))
})();
let (names, edits) = result.map_err(|error| {
Failure::index(
&self.indexed.resolved.syntax,
lower::Failure {
file: failure_file,
error,
},
)
})?;
let plan = RenamePlan {
source: self,
desired,
names,
edits,
};
plan.validate()?;
Ok(plan)
}
}
fn insert_edit(
storage: &mut Ledger,
budget: &Budget,
edits: &mut BTreeMap<OrdinaryKey, Target>,
key: OrdinaryKey,
target: Target,
) -> Result<(), TypedError> {
let at = Span {
start: key.1,
end: key.2,
};
if edits.contains_key(&key) {
return Err(error(at, "Duplicate rename physical token."));
}
storage.reserve(budget, Slots::entries(1), at)?;
edits.insert(key, target);
Ok(())
}
impl<'s, 't> RenamePlan<'s, 't> {
pub fn source(&self) -> &AuthoredMixedProject<'s> {
&self.source
}
pub fn desired(&self) -> &'t Project {
self.desired
}
pub fn changed_names(&self) -> &BTreeMap<Target, &'t str> {
&self.names
}
pub fn edits(&self) -> &BTreeMap<OrdinaryKey, Target> {
&self.edits
}
pub fn validate_current(&self, current: &SourceBundle) -> Result<(), Failure> {
let old = self.source.bundle();
if old.entry != current.entry || old.files != current.files {
return Err(self.failure(
None,
error(
Span::default(),
"Current complete bundle differs from the rename snapshot.",
),
));
}
Ok(())
}
fn failure(&self, file: Option<usize>, error: TypedError) -> Failure {
Failure::index(
&self.source.indexed.resolved.syntax,
lower::Failure { file, error },
)
}
fn validate(&self) -> Result<(), Failure> {
let mut previous: Option<OrdinaryKey> = None;
for (&key, target) in &self.edits {
let at = Span {
start: key.1,
end: key.2,
};
let checked = (|| {
let unit = self
.source
.indexed
.resolved
.syntax
.files
.get(key.0)
.ok_or_else(|| error(at, "Rename token has no original file."))?;
let original = self
.source
.names()
.get(target)
.ok_or_else(|| error(at, "Rename token has no original target."))?;
if !self.names.contains_key(target) {
return Err(error(at, "Rename token targets an unchanged name."));
}
if at.start >= at.end {
return Err(error(at, "Rename token must be nonempty."));
}
word(unit, at, original)?;
if previous.is_some_and(|p| p.0 == key.0 && p.2 > key.1) {
return Err(error(at, "Rename token ranges overlap."));
}
Ok(())
})();
checked.map_err(|e| self.failure(Some(key.0), e))?;
previous = Some(key);
}
let mut required = 0;
for (key, location) in self.source.typed_occurrences() {
self.require(
(location.file, location.span.start, location.span.end),
key.target,
&mut required,
)?;
}
for (&key, occurrence) in self.source.ordinary_occurrences() {
self.require(key, Target::Global(occurrence.target), &mut required)?;
}
if required != self.edits.len() {
return Err(self.failure(
None,
error(Span::default(), "Rename edit set contains extra tokens."),
));
}
self.output_bytes()?;
Ok(())
}
fn require(
&self,
key: OrdinaryKey,
target: Target,
required: &mut usize,
) -> Result<(), Failure> {
if self.names.contains_key(&target) {
let at = Span {
start: key.1,
end: key.2,
};
if self.edits.get(&key) != Some(&target) {
return Err(self.failure(
Some(key.0),
error(at, "Rename edit set is missing a required target token."),
));
}
*required = add(*required, 1, at).map_err(|e| self.failure(Some(key.0), e))?;
}
Ok(())
}
pub fn output_bytes(&self) -> Result<usize, Failure> {
let result = (|| {
let mut total = 0;
let mut edits = self.edits.iter().peekable();
for (file, _, original) in self.source.files() {
let mut removed = 0;
let mut replaced = 0;
while let Some((&key, target)) = edits.peek().copied() {
if key.0 != file {
break;
}
let at = Span {
start: key.1,
end: key.2,
};
removed = add(
removed,
key.2
.checked_sub(key.1)
.ok_or_else(|| error(at, "Invalid rename token size."))?,
at,
)?;
replaced = add(
replaced,
self.names
.get(target)
.ok_or_else(|| error(at, "Rename token has no replacement."))?
.len(),
at,
)?;
edits.next();
}
let size = original.len().checked_sub(removed).ok_or_else(|| {
error(Span::default(), "Rename removals exceed original file.")
})?;
total = add(
total,
add(size, replaced, Span::default())?,
Span::default(),
)?;
}
if edits.next().is_some() {
return Err(error(
Span::default(),
"Rename edit file is outside original bundle.",
));
}
self.source
.indexed
.resolved
.syntax
.budget
.index_bytes(total, Span::default())?;
Ok(total)
})();
result.map_err(|e| self.failure(None, e))
}
}
// Exhaustive exclusion patterns make every new field an explicit gate decision.
macro_rules! fixed {
($old:expr, $new:expr, $ty:path, [$($field:ident),*], [$($excluded:ident),*]) => {{
let $ty { $($field,)* $($excluded: _,)* } = $old;
true $(&& $field == &$new.$field)*
}};
}
fn map_same<T>(
old: &BTreeMap<Id, T>,
new: &BTreeMap<Id, T>,
same: impl Fn(&T, &T) -> bool,
) -> bool {
old.len() == new.len()
&& old
.iter()
.zip(new)
.all(|((a, x), (b, y))| a == b && same(x, y))
}
fn same_project(a: &Project, b: &Project) -> bool {
fixed!(
a,
b,
Project,
[schema_version, id, name, description],
[
alias,
definitions,
elements,
relations,
diagrams,
artifact_links,
traces
]
) && map_same(&a.definitions, &b.definitions, |a, b| {
fixed!(
a,
b,
analysis::Definition,
[id, name, description],
[alias, body]
) && match (&a.body, &b.body) {
(
analysis::DefinitionBody::Calculation(a),
analysis::DefinitionBody::Calculation(b),
) => {
a.parameters.len() == b.parameters.len()
&& a.parameters
.iter()
.zip(&b.parameters)
.all(|(a, b)| fixed!(a, b, analysis::Parameter, [id, value_type], [name]))
&& fixed!(
a,
b,
analysis::CalculationDefinition,
[result, body],
[parameters]
)
}
(
analysis::DefinitionBody::QuantityKind(a),
analysis::DefinitionBody::QuantityKind(b),
) => a == b,
(analysis::DefinitionBody::Unit(a), analysis::DefinitionBody::Unit(b)) => a == b,
_ => false,
}
}) && map_same(&a.elements, &b.elements, |a, b| {
fixed!(
a,
b,
crate::Element,
[id, name, description, parent, body],
[alias]
)
}) && map_same(&a.relations, &b.relations, |a, b| {
fixed!(
a,
b,
crate::Relation,
[id, name, description, body],
[alias]
)
}) && map_same(&a.diagrams, &b.diagrams, |a, b| {
fixed!(
a,
b,
crate::Diagram,
[
id,
name,
description,
layer,
projection,
view_options,
routes
],
[alias, nodes]
) && map_same(&a.nodes, &b.nodes, |a, b| {
fixed!(
a,
b,
crate::NodePlacement,
[id, element, position, size, collapsed, port_anchors],
[alias]
)
})
}) && map_same(&a.artifact_links, &b.artifact_links, |a, b| {
fixed!(
a,
b,
crate::ArtifactLink,
[
id,
name,
description,
owner,
relation,
uri,
revision,
fragment
],
[alias]
)
}) && map_same(&a.traces, &b.traces, |a, b| {
fixed!(
a,
b,
crate::Trace,
[id, name, description, oa, sa, kind, baseline],
[alias]
)
})
}
fn visit_names<'t>(
project: &'t Project,
f: &mut impl FnMut(Target, &'t str) -> Result<(), TypedError>,
) -> Result<(), TypedError> {
f(Target::Global(project.id), &project.alias)?;
for d in project.definitions.values() {
f(Target::Global(d.id), &d.alias)?;
if let analysis::DefinitionBody::Calculation(c) = &d.body {
for p in &c.parameters {
f(
Target::Parameter {
calculation: d.id,
parameter: p.id,
},
&p.name,
)?;
}
}
}
for e in project.elements.values() {
f(Target::Global(e.id), &e.alias)?;
}
for r in project.relations.values() {
f(Target::Global(r.id), &r.alias)?;
}
for d in project.diagrams.values() {
f(Target::Global(d.id), &d.alias)?;
for n in d.nodes.values() {
f(Target::Global(n.id), &n.alias)?;
}
}
for a in project.artifact_links.values() {
f(Target::Global(a.id), &a.alias)?;
}
for t in project.traces.values() {
f(Target::Global(t.id), &t.alias)?;
}
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,952 @@
use super::super::super::tests::{accepted, id, rich_bundle, rich_model, single};
use super::*;
use crate::language::analysis_source::budget::Limits;
use crate::language::analysis_source::mixed::parse_with_profile;
use crate::source_project::ProjectLimits;
use crate::{Layer, analysis as a};
fn authored(bundle: &SourceBundle) -> AuthoredMixedProject<'_> {
accepted(bundle).index().unwrap().index_ordinary().unwrap()
}
fn calculation(p: &mut Project, n: usize) -> &mut a::CalculationDefinition {
let a::DefinitionBody::Calculation(c) = &mut p.definitions.get_mut(&id(n)).unwrap().body else {
panic!("calculation")
};
c
}
fn renamed(layer: Layer) -> Project {
let mut p = rich_model(layer);
p.alias = "w".into();
p.elements.get_mut(&id(2)).unwrap().alias = "p".into();
p.elements.get_mut(&id(3)).unwrap().alias = "n".into();
let d = p.diagrams.get_mut(&id(5)).unwrap();
d.alias = "z".into();
d.nodes.get_mut(&id(4)).unwrap().alias = "v".into();
for (n, name) in [(10, "g"), (11, "f")] {
p.definitions.get_mut(&id(n)).unwrap().alias = name.into();
let c = calculation(&mut p, n);
c.parameters[0].name = "y".into();
c.parameters[1].name = "x".into();
}
if layer == Layer::Sa {
p.elements.get_mut(&id(6)).unwrap().alias = "u".into();
}
p
}
fn rejected(bundle: &SourceBundle, desired: &Project, contains: &str) {
let e = match authored(bundle).plan_rename(desired) {
Ok(_) => panic!("unexpected accepted target"),
Err(e) => e,
};
assert_eq!(e.diagnostics.len(), 1);
assert!(
e.diagnostics[0].diagnostic.message.contains(contains),
"{:?}",
e
);
}
#[test]
fn noop_borrows_exact_target_and_preserves_source_usage_and_storage() {
let bundle = rich_bundle(Layer::Oa);
let source = authored(&bundle);
let desired = rich_model(Layer::Oa);
let before = source.indexed.resolved.storage.live;
let usage = source.indexed.resolved.syntax.budget.usage;
let numeric = source.indexed.resolved.syntax.budget.numeric_units();
let plan = source.plan_rename(&desired).unwrap();
assert!(std::ptr::eq(plan.desired(), &desired));
assert!(std::ptr::eq(plan.source().bundle(), &bundle));
assert!(plan.edits().is_empty());
assert!(plan.changed_names().is_empty());
assert_eq!(plan.source.indexed.resolved.storage.live, before);
assert_eq!(plan.source.indexed.resolved.syntax.budget.usage, usage);
assert_eq!(
plan.source.indexed.resolved.syntax.budget.numeric_units(),
numeric
);
assert_eq!(desired, rich_model(Layer::Oa));
assert_eq!(plan.source.project(), &desired);
assert_eq!(plan.output_bytes().unwrap(), 900);
}
#[test]
fn final_names_reject_unchanged_collisions_invalid_names_and_scoped_collisions() {
let bundle = rich_bundle(Layer::Oa);
for alias in ["w", "", "雨", "project", "bad-name"] {
let mut desired = rich_model(Layer::Oa);
desired.alias = alias.into();
rejected(
&bundle,
&desired,
if alias == "w" { "collide" } else { "Invalid" },
);
}
let mut desired = rich_model(Layer::Oa);
calculation(&mut desired, 10).parameters[0].name = "y".into();
rejected(&bundle, &desired, "collide");
let mut desired = rich_model(Layer::Oa);
calculation(&mut desired, 10).parameters[0].name = "p".into();
calculation(&mut desired, 11).parameters[0].name = "p".into();
let plan = authored(&bundle).plan_rename(&desired).unwrap();
assert_eq!(plan.changed_names().len(), 2);
}
#[test]
fn complete_model_gate_rejects_identity_collection_parameter_and_payload_changes() {
let bundle = rich_bundle(Layer::Oa);
type Mutation = Box<dyn Fn(&mut Project)>;
let mutations: Vec<Mutation> = vec![
Box::new(|p| p.schema_version = 7),
Box::new(|p| p.id = id(99)),
Box::new(|p| p.name.push('!')),
Box::new(|p| p.description.push('!')),
Box::new(|p| {
p.elements.remove(&id(2));
}),
Box::new(|p| {
let e = p.elements.remove(&id(2)).unwrap();
p.elements.insert(id(99), e);
}),
Box::new(|p| p.elements.get_mut(&id(2)).unwrap().id = id(99)),
Box::new(|p| p.elements.get_mut(&id(2)).unwrap().name.push('!')),
Box::new(|p| p.elements.get_mut(&id(2)).unwrap().description.push('!')),
Box::new(|p| p.elements.get_mut(&id(2)).unwrap().parent = Some(id(3))),
Box::new(|p| {
p.elements.get_mut(&id(2)).unwrap().body =
crate::ElementBody::Sa(crate::SaBody::Function)
}),
Box::new(|p| p.definitions.get_mut(&id(10)).unwrap().id = id(99)),
Box::new(|p| p.definitions.get_mut(&id(10)).unwrap().name.push('!')),
Box::new(|p| {
p.definitions
.get_mut(&id(10))
.unwrap()
.description
.push('!')
}),
Box::new(|p| calculation(p, 10).parameters.swap(0, 1)),
Box::new(|p| {
calculation(p, 10).parameters.pop();
}),
Box::new(|p| {
let q = calculation(p, 10).parameters[0].clone();
calculation(p, 10).parameters.push(q);
}),
Box::new(|p| calculation(p, 10).parameters[0].id = a::ParameterId(id(99))),
Box::new(|p| {
calculation(p, 10).parameters[0].value_type =
a::AnalysisType::Scalar(a::ScalarType::Boolean)
}),
Box::new(|p| calculation(p, 10).result = a::AnalysisType::Scalar(a::ScalarType::Boolean)),
Box::new(|p| calculation(p, 10).body = a::Binding::Unbound),
Box::new(|p| p.diagrams.get_mut(&id(5)).unwrap().id = id(99)),
Box::new(|p| p.diagrams.get_mut(&id(5)).unwrap().name.push('!')),
Box::new(|p| p.diagrams.get_mut(&id(5)).unwrap().description.push('!')),
Box::new(|p| p.diagrams.get_mut(&id(5)).unwrap().layer = Layer::Sa),
Box::new(|p| {
p.diagrams
.get_mut(&id(5))
.unwrap()
.view_options
.hidden_edges
.push(id(99))
}),
Box::new(|p| {
p.diagrams.get_mut(&id(5)).unwrap().nodes.clear();
}),
Box::new(|p| {
p.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.id = id(99)
}),
Box::new(|p| {
p.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.element = id(2)
}),
Box::new(|p| {
p.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.position
.x = 1.
}),
Box::new(|p| {
p.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.size
.width = 1.
}),
Box::new(|p| {
p.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.collapsed = true
}),
];
for mutate in mutations {
let mut p = rich_model(Layer::Oa);
mutate(&mut p);
rejected(&bundle, &p, "non-name");
}
}
#[test]
fn complete_current_bundle_rejects_entry_paths_missing_extra_and_untouched_bytes() {
let bundle = rich_bundle(Layer::Oa);
let mut desired = rich_model(Layer::Oa);
desired.alias = "renamed".into();
let plan = authored(&bundle).plan_rename(&desired).unwrap();
plan.validate_current(&bundle).unwrap();
for case in 0..6 {
let mut current = bundle.clone();
match case {
0 => current.entry = "a.arcadia".into(),
1 => {
current.files.remove("a.arcadia");
}
2 => {
current.files.insert("extra.arcadia".into(), String::new());
}
3 => current.files.get_mut("a.arcadia").unwrap().push(' '),
4 => current.files.get_mut("main.arcadia").unwrap().push(' '),
_ => {
let s = current.files.remove("a.arcadia").unwrap();
current.files.insert("renamed.arcadia".into(), s);
}
}
assert!(plan.validate_current(&current).is_err());
}
}
#[test]
fn exact_merged_map_cap_passes_all_predecessors_and_retains_r_plus_m() {
let bundle = rich_bundle(Layer::Oa);
let desired = renamed(Layer::Oa);
let baseline = authored(&bundle).indexed.resolved.storage.live;
for (extra, fits) in [(37, false), (38, true)] {
let profile = Limits {
entries: baseline.entries + extra,
..Limits::source8(language::ParseLimits::UNLIMITED)
};
let source =
resolve(parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap())
.unwrap()
.index()
.unwrap()
.index_ordinary()
.unwrap();
assert_eq!(source.indexed.resolved.storage.live, baseline);
let result = source.plan_rename(&desired);
if fits {
let plan = result.unwrap();
assert_eq!(
plan.source.indexed.resolved.storage.live,
baseline.add(Slots::entries(38), Span::default()).unwrap()
);
} else {
let e = match result {
Ok(_) => panic!("cap accepted"),
Err(e) => e,
};
assert_eq!(
e.diagnostics[0].diagnostic.message,
"Source8 lowering entry limit exceeded."
);
assert_eq!(e.diagnostics[0].path, "main.arcadia");
assert_eq!(
e.diagnostics[0].diagnostic.span,
Span {
start: 412,
end: 413
}
);
}
}
}
#[test]
fn damaged_complete_edits_fail_missing_extra_wrong_target_and_utf8_authority() {
let mut bundle = rich_bundle(Layer::Oa);
for s in bundle.files.values_mut() {
*s = format!("// 雨\r\n{s}");
}
let desired = renamed(Layer::Oa);
for case in 0..7 {
let mut plan = authored(&bundle).plan_rename(&desired).unwrap();
let key = *plan.edits.keys().next().unwrap();
match case {
0 => {
plan.edits.remove(&key);
}
1 => {
plan.edits.insert((0, 0, 1), Target::Global(id(10)));
}
2 => {
plan.edits.insert(key, Target::Global(id(11)));
}
3 => {
plan.edits.insert((0, 4, 5), Target::Global(id(10)));
}
4 => {
plan.edits
.insert((99, key.1, key.2), Target::Global(id(10)));
}
5 => {
plan.edits
.insert((0, key.1, key.2 + 1), Target::Global(id(10)));
}
_ => {
plan.edits.insert((0, key.1, key.1), Target::Global(id(10)));
}
}
let failure = plan.validate().unwrap_err();
assert_eq!(failure.diagnostics.len(), 1);
if case == 3 || case == 4 {
assert_eq!(failure.diagnostics[0].diagnostic.span, Span::default());
assert_eq!(
failure.diagnostics[0].path,
if case == 3 {
"a.arcadia"
} else {
"main.arcadia"
}
);
}
if case == 0 {
assert_eq!(failure.diagnostics[0].path, "a.arcadia");
assert_eq!(
failure.diagnostics[0].diagnostic.span,
Span { start: 20, end: 21 }
);
assert_eq!(
failure.diagnostics[0].diagnostic.message,
"Rename edit set is missing a required target token."
);
}
}
}
fn categories() -> SourceBundle {
SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([(
"main.arcadia".into(),
include_str!("../tests/fixtures/categories.arcadia").into(),
)]),
}
}
#[test]
fn every_ordinary_record_field_group_is_fixed_including_historical_payloads() {
let bundle = categories();
let original = authored(&bundle).project().clone();
type Mutation = Box<dyn Fn(&mut Project)>;
let changes: Vec<Mutation> = vec![
Box::new(|p| p.relations.get_mut(&id(6)).unwrap().id = id(99)),
Box::new(|p| p.relations.get_mut(&id(6)).unwrap().name.push('!')),
Box::new(|p| p.relations.get_mut(&id(6)).unwrap().description.push('!')),
Box::new(|p| {
let body = p.relations[&id(15)].body.clone();
p.relations.get_mut(&id(6)).unwrap().body = body;
}),
Box::new(|p| {
p.relations.remove(&id(6));
}),
Box::new(|p| {
p.diagrams.get_mut(&id(7)).unwrap().projection = crate::ProjectionKind::Allocation
}),
Box::new(|p| p.diagrams.get_mut(&id(7)).unwrap().routes.clear()),
Box::new(|p| {
p.diagrams.remove(&id(7));
}),
Box::new(|p| p.artifact_links.get_mut(&id(8)).unwrap().id = id(99)),
Box::new(|p| p.artifact_links.get_mut(&id(8)).unwrap().name.push('!')),
Box::new(|p| {
p.artifact_links
.get_mut(&id(8))
.unwrap()
.description
.push('!')
}),
Box::new(|p| p.artifact_links.get_mut(&id(8)).unwrap().owner = id(3)),
Box::new(|p| {
p.artifact_links.get_mut(&id(8)).unwrap().relation = crate::ArtifactRelation::Verifies
}),
Box::new(|p| p.artifact_links.get_mut(&id(8)).unwrap().uri.push('!')),
Box::new(|p| p.artifact_links.get_mut(&id(8)).unwrap().revision = Some("changed".into())),
Box::new(|p| p.artifact_links.get_mut(&id(8)).unwrap().fragment = Some("changed".into())),
Box::new(|p| {
p.artifact_links.remove(&id(8));
}),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().id = id(99)),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().name.push('!')),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().description.push('!')),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().oa = id(4)),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().sa = id(4)),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().kind = crate::TraceKind::Refinement),
Box::new(|p| p.traces.get_mut(&id(9)).unwrap().baseline = None),
Box::new(|p| {
p.traces
.get_mut(&id(9))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = None
}),
Box::new(|p| {
p.traces
.get_mut(&id(9))
.unwrap()
.baseline
.as_mut()
.unwrap()
.oa
.name
.push('!')
}),
Box::new(|p| {
p.traces.remove(&id(9));
}),
];
for change in changes {
let mut p = original.clone();
change(&mut p);
rejected(&bundle, &p, "non-name");
}
let source = authored(&bundle);
assert_eq!(source.indexed.resolved.expressions.len(), 0);
drop(source.plan_rename(&original).unwrap());
}
#[test]
fn combined_shrink_offsets_typed_growth_and_counts_untouched_files() {
let mut bundle = rich_bundle(Layer::Oa);
let main = bundle.files.get_mut("main.arcadia").unwrap();
*main = main.replace("node n ", &format!("node {} ", "n".repeat(31)));
assert_eq!(bundle.files.values().map(String::len).sum::<usize>(), 930);
for (name, expected) in [("renamed_f", Some(924)), ("renamed_func", None)] {
let profile = Limits {
bytes: 930,
..Limits::source8(language::ParseLimits::UNLIMITED)
};
let source =
resolve(parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap())
.unwrap()
.index()
.unwrap()
.index_ordinary()
.unwrap();
let mut desired = source.project().clone();
desired
.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.alias = "n".into();
desired.definitions.get_mut(&id(10)).unwrap().alias = name.into();
let result = source.plan_rename(&desired);
if let Some(bytes) = expected {
let plan = result.unwrap();
assert_eq!(plan.output_bytes().unwrap(), bytes);
assert_eq!(plan.edits().len(), 4);
} else {
let e = match result {
Ok(_) => panic!("oversize accepted"),
Err(e) => e,
};
assert_eq!(
e.diagnostics[0].diagnostic.message,
"Source8 index/output byte limit exceeded."
);
}
}
}
#[test]
fn deep_and_wide_equality_and_late_refusal_drop_on_small_stack() {
std::thread::Builder::new()
.stack_size(128 * 1024)
.spawn(|| {
for (expression, result, nodes) in [
(
format!("{}1{}", "abs(".repeat(127), ")".repeat(127)),
"Number",
128,
),
(
format!("[{}]", vec!["1"; 128].join(", ")),
"List(Number)",
129,
),
] {
let bundle = single(&format!(
"calculation c \"C\" id \"{}\" {{ result {result}; body {expression}; }}",
id(2)
));
let source = authored(&bundle);
assert_eq!(source.indexed.resolved.expressions.len(), nodes);
let original = source.project().clone();
drop(source.plan_rename(&original).unwrap());
let mut desired = original.clone();
desired.definitions.get_mut(&id(2)).unwrap().alias = "p".into();
rejected(&bundle, &desired, "collide");
let mut desired = original.clone();
calculation(&mut desired, 2).body = a::Binding::Unbound;
rejected(&bundle, &desired, "non-name");
}
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn zero_expression_original_rejects_target_only_deep_and_wide_variants() {
let bundle = single(&format!(
"calculation c \"C\" id \"{}\" {{ result Number; body unbound; }}",
id(2)
));
let source = authored(&bundle);
assert_eq!(source.indexed.resolved.expressions.len(), 0);
let original = source.project().clone();
let mut deep = a::Expr::Boolean(true);
for _ in 0..2048 {
deep = a::Expr::Unary {
op: a::UnaryOp::Not,
operand: Box::new(deep),
};
}
for expression in [deep, a::Expr::List(vec![a::Expr::Boolean(true); 4096])] {
let mut desired = original.clone();
calculation(&mut desired, 2).body = a::Binding::Expression(expression);
rejected(&bundle, &desired, "non-name");
}
}
#[test]
fn signed_zero_geometry_uses_existing_semantic_equality() {
let bundle = rich_bundle(Layer::Oa);
let mut desired = rich_model(Layer::Oa);
desired
.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.position
.x = -0.;
assert!(
authored(&bundle)
.plan_rename(&desired)
.unwrap()
.edits()
.is_empty()
);
}
fn parameter(c: usize, p: usize) -> Target {
Target::Parameter {
calculation: id(c),
parameter: a::ParameterId(id(p)),
}
}
#[test]
fn independent_entire_rich_maps_oa_sa_unicode_crlf_and_borrowed_names() {
for layer in [Layer::Oa, Layer::Sa] {
for crlf in [false, true] {
let mut bundle = rich_bundle(layer);
if crlf {
for source in bundle.files.values_mut() {
*source = format!("// 雨\r\n{}", source.replace('\n', "\r\n"));
}
}
let desired = renamed(layer);
let source = authored(&bundle);
let before = source.indexed.resolved.storage.live;
let usage = source.indexed.resolved.syntax.budget.usage;
let plan = source.plan_rename(&desired).unwrap();
let mut names = BTreeMap::from([
(Target::Global(id(1)), "w"),
(Target::Global(id(2)), "p"),
(Target::Global(id(3)), "n"),
(Target::Global(id(4)), "v"),
(Target::Global(id(5)), "z"),
(Target::Global(id(10)), "g"),
(Target::Global(id(11)), "f"),
(parameter(10, 1), "y"),
(parameter(10, 12), "x"),
(parameter(11, 1), "y"),
(parameter(11, 12), "x"),
]);
if layer == Layer::Sa {
names.insert(Target::Global(id(6)), "u");
}
let mut edits = BTreeMap::new();
let offsets = if crlf {
[20, 77, 138, 214, 219]
} else {
[12, 68, 128, 203, 208]
};
for (file, calculation) in [(0, 10), (1, 11)] {
for (start, target) in offsets.into_iter().zip([
Target::Global(id(calculation)),
parameter(calculation, 1),
parameter(calculation, 12),
parameter(calculation, 1),
parameter(calculation, 12),
]) {
edits.insert((file, start, start + 1), target);
}
}
let offsets = if crlf {
[
28, 133, 188, 200, 218, 281, 303, 305, 311, 315, 317, 323, 333, 335, 341, 363,
426,
]
} else {
[
19, 122, 177, 189, 206, 269, 291, 293, 299, 303, 305, 311, 321, 323, 329, 350,
412,
]
};
let g = |n| Target::Global(id(n));
for (start, target) in offsets.into_iter().zip([
g(1),
g(4),
g(5),
g(3),
g(3),
g(2),
g(10),
parameter(10, 12),
parameter(10, 1),
g(11),
parameter(11, 1),
parameter(11, 12),
g(10),
parameter(10, 1),
parameter(10, 12),
g(2),
g(5),
]) {
edits.insert((2, start, start + 1), target);
}
if layer == Layer::Sa {
let start = if crlf { 505 } else { 490 };
edits.insert((2, start, start + 1), g(6));
}
assert_eq!(plan.changed_names(), &names);
assert_eq!(plan.edits(), &edits);
let delta = if layer == Layer::Oa { 38 } else { 40 };
assert_eq!(
plan.source.indexed.resolved.storage.live,
before.add(Slots::entries(delta), Span::default()).unwrap()
);
assert_eq!(plan.source.indexed.resolved.syntax.budget.usage, usage);
assert!(std::ptr::eq(plan.desired(), &desired));
assert_eq!(desired, renamed(layer));
assert_eq!(plan.source.project(), &rich_model(layer));
assert!(std::ptr::eq(
plan.names[&g(1)].as_ptr(),
desired.alias.as_ptr()
));
assert_eq!(
plan.output_bytes().unwrap(),
match (layer, crlf) {
(Layer::Oa, false) => 900,
(Layer::Oa, true) => 943,
(Layer::Sa, false) => 974,
(Layer::Sa, true) => 1017,
}
);
}
}
}
#[test]
fn independent_entire_category_map_includes_comment_separated_refs_and_excludes_history() {
let bundle = categories();
let source = authored(&bundle);
let mut desired = source.project().clone();
let replacements = [
"a", "p", "implicit", "child", "link", "s", "note", "d", "q", "t", "use", "sub", "right",
"left", "ctx",
];
desired.alias = replacements[0].into();
for e in desired.elements.values_mut() {
e.alias = replacements[(1..=15).find(|n| id(*n) == e.id).unwrap() - 1].into();
}
for r in desired.relations.values_mut() {
r.alias = replacements[(1..=15).find(|n| id(*n) == r.id).unwrap() - 1].into();
}
desired.diagrams.get_mut(&id(7)).unwrap().alias = "note".into();
desired.artifact_links.get_mut(&id(8)).unwrap().alias = "d".into();
desired.traces.get_mut(&id(9)).unwrap().alias = "q".into();
let before = source.indexed.resolved.storage.live;
assert_eq!(source.indexed.resolved.expressions.len(), 0);
let plan = source.plan_rename(&desired).unwrap();
let spans: &[(usize, &[(usize, usize)])] = &[
(1, &[(19, 20)]),
(2, &[(168, 169), (344, 345)]),
(3, &[(249, 254), (625, 630), (992, 997), (1741, 1746)]),
(4, &[(369, 377), (640, 648), (1882, 1890)]),
(5, &[(487, 488), (1003, 1004)]),
(6, &[(561, 565), (741, 745), (789, 793), (885, 889)]),
(7, &[(664, 665)]),
(8, &[(818, 822)]),
(9, &[(925, 926)]),
(
10,
&[(1328, 1329), (1577, 1578), (1855, 1856), (2004, 2005)],
),
(11, &[(1422, 1425), (1617, 1620), (1712, 1715)]),
(12, &[(1391, 1394), (1514, 1517), (2029, 2032)]),
(13, &[(1487, 1491), (1648, 1652), (2047, 2051)]),
(14, &[(1397, 1402), (1789, 1794), (2092, 2097)]),
(15, &[(1935, 1942)]),
];
let expected: BTreeMap<_, _> = spans
.iter()
.flat_map(|(n, spans)| {
spans
.iter()
.map(move |&(start, end)| ((0, start, end), Target::Global(id(*n))))
})
.collect();
assert_eq!(plan.edits(), &expected);
let names: BTreeMap<_, _> = replacements
.iter()
.enumerate()
.map(|(i, &name)| (Target::Global(id(i + 1)), name))
.collect();
assert_eq!(plan.changed_names(), &names);
assert_eq!(plan.output_bytes().unwrap(), 2145);
assert_eq!(
plan.source.indexed.resolved.storage.live,
before.add(Slots::entries(51), Span::default()).unwrap()
);
}
#[test]
fn signed_zero_scalar_bounds_defaults_and_historical_facets_preserve_original_bytes() {
let bundle = single(&format!(
r#"
oa_data_type a "A" id "{}" {{ definition {{ Real {{ minimum 0.0; maximum 1.0; }}; }}; default {{ Real 0.0; }}; }}
sa_data_type b "B" id "{}" {{ definition {{ Real {{ minimum 0.0; maximum 1.0; }}; }}; default {{ Real 0.0; }}; }}
trace t "T" id "{}" {{ oa @a; sa @b; kind Item; baseline {{
oa {{ name "A"; description ""; }}; sa {{ name "B"; description ""; }};
semantic {{ facets [{{ Data {{ RealMinimum {{ oa {{ Value 0.0; }}; sa {{ Value 0.0; }}; }}; }}; }}]; }};
}}; }}
"#,
id(2),
id(3),
id(4)
));
let source = authored(&bundle);
let mut desired = source.project().clone();
for e in desired.elements.values_mut() {
let body = match &mut e.body {
crate::ElementBody::Oa(crate::OaBody::Shared(b))
| crate::ElementBody::Sa(crate::SaBody::Shared(b)) => b,
_ => panic!("data"),
};
let crate::SharedBody::DataType {
definition,
default,
..
} = body
else {
panic!("data")
};
let crate::DataTypeDefinition::Real { minimum, .. } = definition else {
panic!("real")
};
*minimum = Some(-0.0);
*default = Some(crate::DataValue::Real(-0.0));
}
let facet = &mut desired
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets[0];
let crate::semantic::FacetReview::Data(crate::semantic::DataReview::RealMinimum(review)) =
facet
else {
panic!("historical real")
};
review.oa = crate::semantic::Observed::Value(Some(-0.0));
review.sa = crate::semantic::Observed::Value(Some(-0.0));
let plan = source.plan_rename(&desired).unwrap();
assert!(plan.edits().is_empty());
assert!(std::ptr::eq(plan.source.bundle(), &bundle));
}
#[test]
fn nonzero_expression_lists_calls_and_definition_payloads_reject_changes() {
let bundle = single(&format!(
"calculation c \"C\" id \"{}\" {{ result List(Number); body [1,2]; }}",
id(2)
));
let original = authored(&bundle).project().clone();
for bigger in [false, true] {
let mut desired = original.clone();
let a::Binding::Expression(a::Expr::List(items)) = &mut calculation(&mut desired, 2).body
else {
panic!("list")
};
if bigger {
items.push(a::Expr::Boolean(true));
} else {
items.pop();
}
rejected(&bundle, &desired, "non-name");
}
let bundle = rich_bundle(Layer::Oa);
let mut desired = rich_model(Layer::Oa);
let crate::ElementBody::Oa(crate::OaBody::Shared(crate::SharedBody::EngineeringProperty {
base: a::Binding::Expression(a::Expr::Binary { left, .. }),
..
})) = &mut desired.elements.get_mut(&id(3)).unwrap().body
else {
panic!("property")
};
let a::Expr::Call { arguments, .. } = left.as_mut() else {
panic!("call")
};
arguments.remove(&a::ParameterId(id(1)));
rejected(&bundle, &desired, "non-name");
let mut desired = rich_model(Layer::Oa);
desired
.diagrams
.get_mut(&id(5))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.port_anchors
.push(crate::PortAnchor {
port: id(99),
side: crate::PortAnchorSide::Left,
offset: 0.,
});
rejected(&bundle, &desired, "non-name");
// Opaque quantity/unit payload comparison is tested directly, independently
// of admission's semantic dimension rules; these are gate-level controls.
let mut old = rich_model(Layer::Oa);
old.definitions.get_mut(&id(10)).unwrap().body =
a::DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: a::QuantityAlgebra::Linear,
});
let mut changed = old.clone();
let a::DefinitionBody::QuantityKind(q) =
&mut changed.definitions.get_mut(&id(10)).unwrap().body
else {
unreachable!()
};
q.algebra = a::QuantityAlgebra::Affine {
difference: a::DefinitionRef::Local(id(11)),
};
assert!(!same_project(&old, &changed));
old.definitions.get_mut(&id(10)).unwrap().body = a::DefinitionBody::Unit(a::UnitDefinition {
kind: a::DefinitionRef::Local(id(11)),
scale: crate::analysis_numeric::Exact::from_integer(1),
offset: crate::analysis_numeric::Exact::from_integer(0),
symbol: "u".into(),
});
let mut changed = old.clone();
let a::DefinitionBody::Unit(u) = &mut changed.definitions.get_mut(&id(10)).unwrap().body else {
unreachable!()
};
u.symbol.push('x');
assert!(!same_project(&old, &changed));
assert!(!same_project(&old, &rich_model(Layer::Oa)));
}
#[test]
fn same_spelling_different_callee_and_unchanged_target_corruptions_are_rejected() {
let bundle = rich_bundle(Layer::Oa);
let desired = renamed(Layer::Oa);
let mut plan = authored(&bundle).plan_rename(&desired).unwrap();
plan.edits.insert((2, 299, 300), parameter(11, 1));
let e = plan.validate().unwrap_err();
assert_eq!(e.diagnostics[0].path, "main.arcadia");
assert_eq!(
e.diagnostics[0].diagnostic.span,
Span {
start: 299,
end: 300
}
);
assert_eq!(
e.diagnostics[0].diagnostic.message,
"Rename edit set is missing a required target token."
);
let mut desired = rich_model(Layer::Oa);
desired.alias = "new_project".into();
let mut plan = authored(&bundle).plan_rename(&desired).unwrap();
plan.edits.insert((2, 122, 123), Target::Global(id(4)));
let e = plan.validate().unwrap_err();
assert_eq!(
e.diagnostics[0].diagnostic.span,
Span {
start: 122,
end: 123
}
);
assert_eq!(
e.diagnostics[0].diagnostic.message,
"Rename token targets an unchanged name."
);
}
#[test]
fn included_file_last_reservation_preserves_its_canonical_diagnostic() {
let mut bundle = rich_bundle(Layer::Oa);
let main = bundle.files.get_mut("main.arcadia").unwrap();
let start = main.find("diagram d ").unwrap();
let end = start + main[start..].find('\n').unwrap() + 1;
let diagram = main[start..end].to_owned();
main.replace_range(start..end, "include \"z.arcadia\";\n");
bundle.files.insert("z.arcadia".into(), diagram);
let baseline = authored(&bundle).indexed.resolved.storage.live;
let desired = renamed(Layer::Oa);
// The unchanged-profile pipeline must clear every predecessor. The final
// ordinary header is now in the canonical last include, at d[8..9).
let profile = Limits {
entries: baseline.entries + 37,
..Limits::source8(language::ParseLimits::UNLIMITED)
};
let source = resolve(parse_with_profile(&bundle, ProjectLimits::default(), profile).unwrap())
.unwrap()
.index()
.unwrap()
.index_ordinary()
.unwrap();
let e = match source.plan_rename(&desired) {
Ok(_) => panic!("cap accepted"),
Err(e) => e,
};
assert_eq!(e.diagnostics[0].path, "z.arcadia");
assert_eq!(e.diagnostics[0].diagnostic.span, Span { start: 8, end: 9 });
assert_eq!(
e.diagnostics[0].diagnostic.message,
"Source8 lowering entry limit exceeded."
);
}
@@ -0,0 +1,19 @@
# Private complete-target mixed rename planning
2026-09-24. Descendant implementation of base `ee500908e3d77182ddd54e12524f9b6c6eb43b60`, under accepted parent contract `dc87babf` (`harness/analysis-mixed-rename-plan-contract-20260924.md` on the evidence branch). This note describes source and authored tests, not completed runtime acceptance.
`AuthoredMixedProject::plan_rename` consumes the complete admitted source witness and borrows an immutable whole desired Project. `RenamePlan` retains that witness, desired borrow, changed qualified names and one original-coordinate physical edit map. Read-only methods expose source, desired, names, edits, full-current-bundle validation and combined complete output-byte sizing. There is no emitter, rewritten-output admission, public activation or legacy adapter.
The whole-target gate uses exhaustive record patterns, collection keys/counts and parameter position/identity/type checks. Existing PartialEq semantics govern all unchanged payloads, including signed floating zero. Its expression work vectors run under an E-entry reservation, where E is the retained raw expression-site count. Current expression roots are calculation bindings, engineering-property base, case overrides and check predicates; historical semantic fields have no analysis Expr. E scratch is released before changed names are collected; the final namespace set is dropped before edits are collected. Successful retained growth is R+M entries, zero occurrence slots and zero copied text. Parsing, numeric and frame Usage remain unchanged.
Both authenticated occurrence families feed one map. Validation checks original token spelling/UTF-8 authority, whole Word spans, physical nonoverlap and exact required key/qualified-target membership. Bundle validation includes entry, every path/file and untouched bytes. Sizing subtracts the sum of removed bytes before adding replacements per file, then includes all files in the inherited byte check. Emission/re-admission lifecycle and aggregate output storage remain required successors.
Independent source review found and prompted two pre-compiler repairs: preserve canonical file attribution for edit-reservation failures, and check ordered parameter shape before calculation-body equality. No runtime failure claim is attached to these source findings. Existing historical mixed/ordinary failures and prior baseline results remain unchanged; this work does not overwrite their records.
The 17 authored tests cover independently specified complete rich OA/SA and Unicode/CRLF maps and final-name sets; all36 category edits including real comment-separated references and historical UUID exclusion; identity/map/parameter/non-name field rejections; QuantityKind/Unit payload gate controls; simultaneous global/scoped swaps and collisions; immutable exact target/source borrowing; no-op; unchanged Usage and R+M retention; stale touched/untouched/path/entry/file-set controls; safe corrupted UTF-8/file authority and same-spelling wrong-callee identity; signed-zero geometry, scalar bounds/defaults and historical real facets; deep/wide old equality and target-only trees with small-stack success/drop and late refusal.
Storage oracle: rich OA B=resolved+65, E15/Q11/R11/M27; added peak38 and retention38. Inherited B+37/B+38 tests run the entire predecessor pipeline before planning, and the negative checks the last ordinary diagram header main[412..413). A separate diagram moved to canonical last include z.arcadia checks precise included-file allocation diagnostics, contingent on its complete predecessor pipeline fitting the unchanged profile. No test replaces/tightens a live owner's Budget to manufacture a later-phase failure. Wide/deep equality tests assert authored E128/E129; no unsupported isolated E-cap claim is made.
Combined-size oracle uses original930 bytes: ordinary31-byte node alias shrinks to1 while three typed f aliases grow to9; complete924 fits although typed-only intermediate954 would not. A12-byte replacement produces933 and must fail under the same inherited930-byte cap, including the untouched213-byte file. This tests planned sizing only, never output correctness.
All source/tests require parent compilation, focused runtime tests and existing broader gates. Direct rustfmt is the only executed Rust tool during authoring. Parent owns commit/publication and verification evidence; no Cargo/build/test execution was performed by this implementation agent.