diff --git a/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs b/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs index 474b22ec..e0b83abb 100644 --- a/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs +++ b/crates/oa-core/src/language/analysis_source/mixed/carried/tests.rs @@ -660,3 +660,70 @@ fn ordinary_position_key_copy_is_counted_at_existing_postparse_text_phase() { "Source8 index/output byte limit exceeded.", ); } + +#[test] +fn emission_refuses_changed_carried_even_zero_and_preserves_noop_guards() { + for retained in [false, true] { + let held = owner(empty(), profile(100, 4096)); + let seed = + parse_with_profile(&held.bundle, ProjectLimits::default(), profile(100, 4096)).unwrap(); + for changed in [false, true] { + let input = if retained { + held.input() + } else { + CarriedInput::borrowed(&held.bundle, &seed) + }; + let parsed = parse_carried(input).unwrap(); + let original_guard = parsed.carried.as_ref().unwrap().slots(); + let resolved = project::resolve(parsed).unwrap(); + let source = resolved.index().unwrap().index_ordinary().unwrap(); + let mut desired = source.project().clone(); + if changed { + desired.alias = "next".into(); + } + let plan = source.plan_rename(&desired).unwrap(); + if changed { + let error = match plan.emit_changed_unverified(&held.bundle) { + Ok(_) => panic!("carried changed emission must refuse"), + Err(e) => e, + }; + diagnostic( + &error.diagnostics[0], + "main.arcadia", + 0, + 0, + "Changed emission requires original legacy syntax.", + ); + } else { + let source = plan.into_unchanged(&held.bundle).unwrap(); + assert!(std::ptr::eq(source.bundle(), &held.bundle)); + // The intact owner can be consumed through another no-op plan; + // no production getter exposes or replaces its guard. + let source = source + .plan_rename(&desired) + .unwrap() + .into_unchanged(&held.bundle) + .unwrap(); + assert!(std::ptr::eq(source.bundle(), &held.bundle)); + let mut changed_desired = desired.clone(); + changed_desired.alias = "next".into(); + assert!( + source + .plan_rename(&changed_desired) + .unwrap() + .emit_changed_unverified(&held.bundle) + .is_err() + ); + } + assert_eq!( + original_guard, + if retained { + held.retained + } else { + Slots::default() + } + ); + assert_eq!(held.bundle.files["main.arcadia"], doc("")); + } + } +} diff --git a/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename.rs b/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename.rs index 7adc671e..51554b06 100644 --- a/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename.rs +++ b/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename.rs @@ -1,4 +1,5 @@ -//! Complete-target rename planning only; no output emission or admission. +//! Complete-target rename planning and private unverified emission; no admission. +pub(in crate::language::analysis_source) mod emission; use super::*; use crate::source_project::SourceBundle; use lower::Target; diff --git a/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission.rs b/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission.rs new file mode 100644 index 00000000..a02552f2 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission.rs @@ -0,0 +1,152 @@ +//! Owned output only. No parser, extraction or accepted-result conversion. +use super::*; +use crate::language::analysis_source::budget::Limits; +use crate::source_project::ProjectLimits; + +pub(in crate::language::analysis_source) struct PendingRename<'t> { + bundle: SourceBundle, + limits: Limits, + project_limits: ProjectLimits, + retained: Slots, + expression_nodes: usize, + desired: &'t Project, +} + +impl<'s, 't> RenamePlan<'s, 't> { + pub fn into_unchanged( + self, + current: &SourceBundle, + ) -> Result, Failure> { + self.validate_current(current)?; + self.validate()?; + if !self.names.is_empty() { + return Err(self.failure(None, error(Span::default(), "Rename plan changes names."))); + } + Ok(self.source) + } + + pub fn emit_changed_unverified( + mut self, + current: &SourceBundle, + ) -> Result, Failure> { + self.validate_current(current)?; + self.validate()?; + if self.names.is_empty() { + return Err(self.failure(None, error(Span::default(), "Rename plan is unchanged."))); + } + if self.source.indexed.resolved.syntax.carried.is_some() { + return Err(self.failure( + None, + error( + Span::default(), + "Changed emission requires original legacy syntax.", + ), + )); + } + let total = self.output_bytes()?; + let retained = + output_slots(self.source.bundle(), total).map_err(|e| self.failure(None, e))?; + let resolved = &mut self.source.indexed.resolved; + resolved + .storage + .reserve(&resolved.syntax.budget, retained, Span::default()) + .map_err(|e| self.failure(None, e))?; + + // The complete output reservation precedes every owned byte and entry. + let mut files = BTreeMap::new(); + let mut produced = 0; + for (file, path, original) in self.source.files() { + let result = (|| { + let edits = || { + self.edits + .range((file, 0, 0)..=(file, usize::MAX, usize::MAX)) + }; + let mut removed = 0; + let mut replacement = 0; + for (&(_, start, end), target) in edits() { + let at = Span { start, end }; + removed = add( + removed, + end.checked_sub(start) + .ok_or_else(|| error(at, "Invalid rename token size."))?, + at, + )?; + replacement = add(replacement, self.names[target].len(), at)?; + } + let size = add( + original.len().checked_sub(removed).ok_or_else(|| { + error(Span::default(), "Rename removals exceed original file.") + })?, + replacement, + Span::default(), + )?; + let mut output = String::with_capacity(size); + let mut cursor = 0; + for (&(_, start, end), target) in edits() { + let at = Span { start, end }; + output.push_str( + original + .get(cursor..start) + .ok_or_else(|| error(at, "Invalid rename output range."))?, + ); + output.push_str(self.names[target]); + cursor = end; + } + output.push_str( + original + .get(cursor..) + .ok_or_else(|| error(Span::default(), "Invalid rename output tail."))?, + ); + if output.len() != size { + return Err(error(Span::default(), "Rename output length mismatch.")); + } + produced = add(produced, size, Span::default())?; + Ok(output) + })(); + let output = result.map_err(|e| self.failure(Some(file), e))?; + files.insert(path.to_owned(), output); + } + if files.len() != retained.entries || produced != total { + return Err(self.failure(None, error(Span::default(), "Incomplete rename output."))); + } + let bundle = SourceBundle { + entry: self.source.bundle().entry.clone(), + files, + }; + let limits = self.source.indexed.resolved.syntax.budget.limits(); + let project_limits = self.source.indexed.resolved.syntax.limits; + let expression_nodes = self.source.indexed.resolved.expressions.len(); + let Self { + source, + desired, + names, + edits, + } = self; + drop(names); + drop(edits); + drop(source); + Ok(PendingRename { + bundle, + limits, + project_limits, + retained, + expression_nodes, + desired, + }) + } +} + +fn output_slots(bundle: &SourceBundle, total: usize) -> Result { + let mut text = add(total, bundle.entry.len(), Span::default())?; + for path in bundle.files.keys() { + text = add(text, path.len(), Span::default())?; + } + Ok(Slots { + entries: bundle.files.len(), + occurrences: 0, + text, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission/tests.rs b/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission/tests.rs new file mode 100644 index 00000000..498f5414 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission/tests.rs @@ -0,0 +1,646 @@ +use super::*; +use crate::Layer; +use crate::language::ParseLimits; +use crate::language::analysis_source::mixed::{parse_with_profile, project}; +use project::tests::{accepted, id, rich_bundle, rich_model, single}; + +fn authored(bundle: &SourceBundle) -> AuthoredMixedProject<'_> { + accepted(bundle).index().unwrap().index_ordinary().unwrap() +} +fn profile(entries: usize, bytes: usize) -> Limits { + Limits { + entries, + bytes, + ..Limits::source8(ParseLimits::UNLIMITED) + } +} +fn limited( + bundle: &SourceBundle, + limits: Limits, + policy: ProjectLimits, +) -> AuthoredMixedProject<'_> { + project::resolve(parse_with_profile(bundle, policy, limits).unwrap()) + .unwrap() + .index() + .unwrap() + .index_ordinary() + .unwrap() +} +fn chain() -> SourceBundle { + let mut b = single("include \"part.arcadia\";"); + b.files.insert( + "part.arcadia".into(), + "include \"last.arcadia\";\r\n".into(), + ); + b.files.insert("last.arcadia".into(), "//é\r\n".into()); + b +} +fn failure(result: Result, text: &str) -> Failure { + let e = match result { + Ok(_) => panic!("unexpected success"), + Err(e) => e, + }; + assert_eq!(e.diagnostics.len(), 1); + assert!(e.diagnostics[0].diagnostic.message.contains(text), "{e:?}"); + e +} +fn same_policy(output: &PendingRename<'_>, limits: Limits, policy: ProjectLimits) { + let a = output.limits; + assert_eq!( + [a.bytes, a.tokens, a.entries, a.references, a.frames], + [ + limits.bytes, + limits.tokens, + limits.entries, + limits.references, + limits.frames + ] + ); + assert_eq!(a.numeric_units, limits.numeric_units); + let a = output.project_limits; + assert_eq!( + [ + a.max_files, + a.max_include_depth, + a.max_include_edges, + a.model.max_source_bytes, + a.model.max_graph_objects, + a.model.max_records, + a.model.max_step_references + ], + [ + policy.max_files, + policy.max_include_depth, + policy.max_include_edges, + policy.model.max_source_bytes, + policy.model.max_graph_objects, + policy.model.max_records, + policy.model.max_step_references + ] + ); +} +const NEXT: &str = + "arcadia 8;\nproject next \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\n\n}\n"; +const NEXT_CHAIN: &str = "arcadia 8;\nproject next \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\ninclude \"part.arcadia\";\n}\n"; +#[test] +fn exact_real_single_and_chain_overlap_caps_and_complete_owned_bytes() { + for (bundle, entries, bytes, before, output_entries, output_text, expected) in [ + ( + single(""), + 17, + 114, + Slots { + entries: 16, + occurrences: 0, + text: 9, + }, + 1, + 105, + NEXT, + ), + ( + chain(), + 29, + 216, + Slots { + entries: 26, + occurrences: 0, + text: 33, + }, + 3, + 183, + NEXT_CHAIN, + ), + ] { + for (entry_cap, byte_cap, success) in [ + (entries, bytes, true), + (entries - 1, bytes, false), + (entries, bytes - 1, false), + ] { + let limits = profile(entry_cap, byte_cap); + let policy = ProjectLimits::default(); + let source = limited(&bundle, limits, policy); + let mut desired = source.project().clone(); + desired.alias = "next".into(); + let plan = source.plan_rename(&desired).unwrap(); + assert_eq!(plan.source.indexed.resolved.storage.live, before); + let result = plan.emit_changed_unverified(&bundle); + if success { + let output = result.unwrap(); + assert_eq!( + output.retained, + Slots { + entries: output_entries, + occurrences: 0, + text: output_text + } + ); + assert_eq!(output.bundle.entry, "main.arcadia"); + let mut expected_files = BTreeMap::from([("main.arcadia".into(), expected.into())]); + if output_entries == 3 { + expected_files.insert( + "part.arcadia".into(), + "include \"last.arcadia\";\r\n".into(), + ); + expected_files.insert("last.arcadia".into(), "//é\r\n".into()); + } + assert_eq!(output.bundle.files, expected_files); + assert_eq!(output.expression_nodes, 0); + assert!(std::ptr::eq(output.desired, &desired)); + same_policy(&output, limits, policy); + } else { + let e = failure(result, "limit exceeded"); + assert_eq!(e.diagnostics[0].path, "main.arcadia"); + assert_eq!(e.diagnostics[0].diagnostic.span, Span::default()); + } + } + } +} +#[test] +fn noop_returns_identical_owner_below_output_overlap_and_releases_desired_borrow() { + for (bundle, entries, bytes) in [(single(""), 16, 78), (chain(), 26, 132)] { + let source = limited(&bundle, profile(entries, bytes), ProjectLimits::default()); + let storage = source.indexed.resolved.storage.live; + let usage = source.indexed.resolved.syntax.budget.usage; + let numeric = source.indexed.resolved.syntax.budget.numeric_units(); + let names = source.indexed.data.names.values().next().unwrap().as_ptr(); + let warnings = source.warnings().to_vec(); + let result = { + let desired = source.project().clone(); + source + .plan_rename(&desired) + .unwrap() + .into_unchanged(&bundle) + .unwrap() + }; + assert!(std::ptr::eq(result.bundle(), &bundle)); + assert_eq!( + result.indexed.data.names.values().next().unwrap().as_ptr(), + names + ); + assert_eq!(result.indexed.resolved.storage.live, storage); + assert_eq!(result.indexed.resolved.syntax.budget.usage, usage); + assert_eq!( + result.indexed.resolved.syntax.budget.numeric_units(), + numeric + ); + assert_eq!(result.warnings(), warnings); + } +} +#[test] +fn wrong_branches_and_complete_stale_current_refuse_before_output() { + let bundle = chain(); + let original = authored(&bundle).project().clone(); + let mut desired = original.clone(); + desired.alias = "next".into(); + failure( + authored(&bundle) + .plan_rename(&original) + .unwrap() + .emit_changed_unverified(&bundle), + "unchanged", + ); + failure( + authored(&bundle) + .plan_rename(&desired) + .unwrap() + .into_unchanged(&bundle), + "changes names", + ); + for case in 0..6 { + let mut current = bundle.clone(); + match case { + 0 => current.entry = "last.arcadia".into(), + 1 => current.files.get_mut("last.arcadia").unwrap().push(' '), + 2 => current.files.get_mut("main.arcadia").unwrap().push(' '), + 3 => { + current.files.remove("last.arcadia"); + } + 4 => { + current.files.insert("extra.arcadia".into(), String::new()); + } + _ => { + let text = current.files.remove("last.arcadia").unwrap(); + current.files.insert("renamed.arcadia".into(), text); + } + } + failure( + authored(&bundle) + .plan_rename(&original) + .unwrap() + .into_unchanged(¤t), + "Current complete bundle", + ); + failure( + authored(&bundle) + .plan_rename(&desired) + .unwrap() + .emit_changed_unverified(¤t), + "Current complete bundle", + ); + } + let current = bundle.clone(); + assert_eq!( + authored(&bundle) + .plan_rename(&desired) + .unwrap() + .emit_changed_unverified(¤t) + .unwrap() + .bundle + .files["main.arcadia"], + NEXT_CHAIN + ); +} +#[test] +fn existing_edit_validation_runs_before_both_transitions() { + let bundle = single(""); + let original = authored(&bundle).project().clone(); + let mut desired = original.clone(); + desired.alias = "next".into(); + let mut plan = authored(&bundle).plan_rename(&desired).unwrap(); + plan.edits.clear(); + failure( + plan.emit_changed_unverified(&bundle), + "missing a required target token", + ); + let mut plan = authored(&bundle).plan_rename(&original).unwrap(); + plan.edits.insert((0, 19, 20), Target::Global(id(1))); + failure(plan.into_unchanged(&bundle), "unchanged name"); + let mut plan = authored(&bundle).plan_rename(&desired).unwrap(); + plan.edits.clear(); + plan.edits + .insert((0, usize::MAX, usize::MAX), Target::Global(id(1))); + failure(plan.emit_changed_unverified(&bundle), "nonempty"); + // Arithmetic-only fault injection, not a real bundle large enough to overflow. + assert!(output_slots(&bundle, usize::MAX).is_err()); +} +#[test] +fn exact_all_policy_fields_and_pending_outlives_original_bundle() { + let desired = { + let b = single(""); + let mut p = authored(&b).project().clone(); + p.alias = "next".into(); + p + }; + let limits = Limits { + bytes: 2048, + tokens: 101, + entries: 99, + references: 31, + frames: 17, + numeric_units: 8192, + }; + let policy = ProjectLimits { + max_files: 7, + max_include_depth: 3, + max_include_edges: 5, + model: ParseLimits { + max_source_bytes: 3000, + max_graph_objects: 67, + max_records: 123, + max_step_references: 37, + }, + }; + let pending = { + let b = single(""); + limited(&b, limits, policy) + .plan_rename(&desired) + .unwrap() + .emit_changed_unverified(&b) + .unwrap() + }; + assert_eq!(pending.bundle.files["main.arcadia"], NEXT); + same_policy(&pending, limits, policy); +} +#[test] +fn scoped_small_stack_changed_unchanged_and_failure_drop() { + let bundle = single(""); + let original = authored(&bundle).project().clone(); + let mut changed = original.clone(); + changed.alias = "next".into(); + std::thread::scope(|scope| { + for mode in 0..3 { + let plan = limited( + &bundle, + profile(17, if mode == 2 { 113 } else { 114 }), + ProjectLimits::default(), + ) + .plan_rename(if mode == 1 { &original } else { &changed }) + .unwrap(); + let b = &bundle; + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn_scoped(scope, move || { + if mode == 1 { + let owner = plan.into_unchanged(b).unwrap(); + assert!(std::ptr::eq(owner.bundle(), b)); + drop(owner); + } else if mode == 0 { + let pending = plan.emit_changed_unverified(b).unwrap(); + assert_eq!(pending.bundle.files["main.arcadia"], NEXT); + drop(pending); + } else { + drop(failure(plan.emit_changed_unverified(b), "limit exceeded")); + } + }) + .unwrap() + .join() + .unwrap(); + } + }); + for (expression, result, nodes) in [ + ( + format!("{}1{}", "abs(".repeat(127), ")".repeat(127)), + "Number", + 128, + ), + ( + format!("[{}]", vec!["1"; 128].join(", ")), + "List(Number)", + 129, + ), + ] { + let body = format!( + "calculation c \"C\" id \"00000000-0000-0000-0000-000000000002\" {{ result {result}; body {expression}; }}" + ); + let bundle = single(&body); + let before = bundle.clone(); + let expected = format!( + "arcadia 8;\nproject next \"Project\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}\n" + ); + for changed in [false, true] { + // Complete prerequisites stay outside the bounded emitter worker. + let source = authored(&bundle); + assert_eq!(source.indexed.resolved.expressions.len(), nodes); + let mut desired = source.project().clone(); + if changed { + desired.alias = "next".into(); + } + let plan = source.plan_rename(&desired).unwrap(); + std::thread::scope(|scope| { + let bundle = &bundle; + let expected = &expected; + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn_scoped(scope, move || { + if changed { + let pending = plan.emit_changed_unverified(bundle).unwrap(); + assert_eq!(pending.expression_nodes, nodes); + assert_eq!(&pending.bundle.files["main.arcadia"], expected); + drop(pending); + } else { + let owner = plan.into_unchanged(bundle).unwrap(); + assert!(std::ptr::eq(owner.bundle(), bundle)); + assert_eq!(owner.indexed.resolved.expressions.len(), nodes); + drop(owner); + } + }) + .unwrap() + .join() + .unwrap(); + }); + assert_eq!(bundle, before); + } + } +} +fn rich_desired(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")] { + let def = p.definitions.get_mut(&id(n)).unwrap(); + def.alias = name.into(); + let crate::analysis::DefinitionBody::Calculation(c) = &mut def.body else { + panic!("calculation") + }; + 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 +} +// Independently authored entire expected files: no edit iterator or output text +// contributes to these strings. The LF/CRLF transform applies to whole literals. +const RICH_A: &str = "calculation g \"F\" id \"00000000-0000-0000-0000-000000000010\" {\ninput y id \"00000000-0000-0000-0000-000000000001\" : Number;\ninput x id \"00000000-0000-0000-0000-000000000012\" : Number;\nresult Number; body $y + $x;\n}\n"; +const RICH_B: &str = "calculation f \"G\" id \"00000000-0000-0000-0000-000000000011\" {\ninput y id \"00000000-0000-0000-0000-000000000001\" : Number;\ninput x id \"00000000-0000-0000-0000-000000000012\" : Number;\nresult Number; body $y - $x;\n}\n"; +fn rich_expected(layer: Layer) -> SourceBundle { + let (property, work, diagram, extra) = if layer == Layer::Oa { + ("oa_property", "oa_activity", "", "") + } else { + ( + "sa_property", + "sa_function", + "layer Sa;", + "sa_system u \"System\" id \"00000000-0000-0000-0000-000000000006\" {}", + ) + }; + SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([ + ("a.arcadia".into(), RICH_A.into()), + ("b.arcadia".into(), RICH_B.into()), + ( + "main.arcadia".into(), + format!( + "arcadia 8;\nproject w \"Project\" id \"00000000-0000-0000-0000-000000000001\" {{\ninclude \"b.arcadia\"; include \"a.arcadia\";\nnode v id \"00000000-0000-0000-0000-000000000004\" {{ diagram @z; element @n; }}\n{property} n \"Value\" id \"00000000-0000-0000-0000-000000000003\" {{ subject @p; type Number; base @g(x: 2, y: @f(y: 3, x: 4)) + @g(y: 5, x: 6); }}\n{work} p \"Work\" id \"00000000-0000-0000-0000-000000000002\" {{}}\ndiagram z \"Diagram\" id \"00000000-0000-0000-0000-000000000005\" {{ {diagram} }}\n{extra}\n}}\n" + ), + ), + ]), + } +} +#[test] +fn whole_rich_oa_sa_scoped_global_swaps_and_unicode_crlf_bytes() { + for (layer, bytes, unicode_bytes) in [(Layer::Oa, 900, 943), (Layer::Sa, 974, 1017)] { + for unicode in [false, true] { + let mut bundle = rich_bundle(layer); + let mut expected = rich_expected(layer); + if unicode { + for text in bundle.files.values_mut() { + *text = format!("// 雨\r\n{}", text.replace('\n', "\r\n")); + } + for text in expected.files.values_mut() { + *text = format!("// 雨\r\n{}", text.replace('\n', "\r\n")); + } + } + let desired = rich_desired(layer); + let source = authored(&bundle); + let original_project = source.project().clone(); + let pending = source + .plan_rename(&desired) + .unwrap() + .emit_changed_unverified(&bundle) + .unwrap(); + assert_eq!(pending.bundle, expected); + assert_eq!(pending.expression_nodes, 15); + let count = if unicode { unicode_bytes } else { bytes }; + assert_eq!( + pending.retained, + Slots { + entries: 3, + occurrences: 0, + text: count + 42 + } + ); + assert_eq!(authored(&bundle).project(), &original_project); + assert_eq!(desired, rich_desired(layer)); + } + } +} +#[test] +fn four_file_cross_family_swap_preserves_entire_untouched_file_and_comments() { + let bundle=SourceBundle {entry:"main.arcadia".into(),files:BTreeMap::from([ + ("a.arcadia".into(),"calculation f \"F\" id \"00000000-0000-0000-0000-000000000010\" { result Number; body 1; }\r\n".into()), + ("b.arcadia".into(),"// f and w are comments\r\noa_activity w \"Work\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n".into()), + ("main.arcadia".into(),"// 雨\r\narcadia 8;\r\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\r\ninclude \"a.arcadia\"; include \"b.arcadia\"; include \"untouched.arcadia\";\r\noa_property v \"Value\" id \"00000000-0000-0000-0000-000000000003\" { subject @w; type Number; base @f(); }\r\n}\r\n".into()), + ("untouched.arcadia".into(),"// 雪 untouched f w\r\n".into()), + ])}; + let expected=SourceBundle {entry:"main.arcadia".into(),files:BTreeMap::from([ + ("a.arcadia".into(),"calculation w \"F\" id \"00000000-0000-0000-0000-000000000010\" { result Number; body 1; }\r\n".into()), + ("b.arcadia".into(),"// f and w are comments\r\noa_activity f \"Work\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n".into()), + ("main.arcadia".into(),"// 雨\r\narcadia 8;\r\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\r\ninclude \"a.arcadia\"; include \"b.arcadia\"; include \"untouched.arcadia\";\r\noa_property v \"Value\" id \"00000000-0000-0000-0000-000000000003\" { subject @f; type Number; base @w(); }\r\n}\r\n".into()), + ("untouched.arcadia".into(),"// 雪 untouched f w\r\n".into()), + ])}; + let source = authored(&bundle); + let mut desired = source.project().clone(); + desired.definitions.get_mut(&id(10)).unwrap().alias = "w".into(); + desired.elements.get_mut(&id(2)).unwrap().alias = "f".into(); + let pending = source + .plan_rename(&desired) + .unwrap() + .emit_changed_unverified(&bundle) + .unwrap(); + assert_eq!(pending.bundle, expected); + assert_eq!( + pending.retained, + Slots { + entries: 4, + occurrences: 0, + text: 526 + } + ); + assert_eq!(pending.expression_nodes, 2); +} +const CATEGORY_EXPECTED: &str = r#"arcadia 8; +project a "Authored @p child" id "00000000-0000-0000-0000-000000000001" { + description "Do not index @child, @link or uuid in prose 雨"; + oa_activity p "Parent child" id "00000000-0000-0000-0000-000000000002" { + oa_activity implicit "Explicit child" id "00000000-0000-0000-0000-000000000003" { parent @ // @noise child + p; } + oa_activity child "Implicit child" id "00000000-0000-0000-0000-000000000004" { description "child @a"; } + } + sa_function link "Selected" id "00000000-0000-0000-0000-000000000005" {} + interaction s "Link" id "00000000-0000-0000-0000-000000000006" { source @implicit; target @child; } + diagram note "View" id "00000000-0000-0000-0000-000000000007" { + routes [{ edge @s; }]; + view_options { hidden_edges [@s]; }; + } + artifact d "Evidence" id "00000000-0000-0000-0000-000000000008" { owner @s; uri "docs/@child.md"; } + trace q "Review" id "00000000-0000-0000-0000-000000000009" { + oa @implicit; sa @link; kind Manual; + baseline { + oa { name "child"; description "@child"; }; + sa { name "s"; description "@s"; }; + semantic { facets [{ Field { Type { oa { Value { Named uuid "00000000-0000-0000-0000-000000000003"; }; }; sa { Value Boolean; }; }; }; }]; }; + }; + } + oa_process t "Caller" id "00000000-0000-0000-0000-000000000010" { steps [@sub, @left]; } + oa_process use "Callee" id "00000000-0000-0000-0000-000000000011" { steps [@right]; } + oa_occurrence sub "Call" id "00000000-0000-0000-0000-000000000012" { parent @t; body { SequenceReference { sequence @use; }; }; } + oa_occurrence right "Left" id "00000000-0000-0000-0000-000000000013" { parent @use; body { Action { action @implicit; lifeline none; }; }; } + oa_occurrence left "Right" id "00000000-0000-0000-0000-000000000014" { parent @t; body { Action { action @child; lifeline none; }; }; } + contextual_link ctx "Context" id "00000000-0000-0000-0000-000000000015" { owner @t; source { references [@sub]; occurrence @right; }; target { references []; occurrence @left; }; purpose { Precedence { guard "@child"; }; }; } +} +"#; +#[test] +fn complete_categories_preserve_trivia_implicit_parent_and_historical_uuid_bytes() { + let bundle = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([( + "main.arcadia".into(), + include_str!("../../tests/fixtures/categories.arcadia").into(), + )]), + }; + 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 = "a".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 pending = source + .plan_rename(&desired) + .unwrap() + .emit_changed_unverified(&bundle) + .unwrap(); + let expected = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([( + "main.arcadia".into(), + format!( + "{}\r\n", + CATEGORY_EXPECTED + .trim_end_matches('\n') + .replace("@noise child\n", "@noise child\r\n") + ), + )]), + }; + assert_eq!(pending.bundle, expected); + assert_eq!( + pending.retained, + Slots { + entries: 1, + occurrences: 0, + text: 2169 + } + ); + assert_eq!(pending.expression_nodes, 0); +} +#[test] +fn combined_growth_shrinkage_emits_original_coordinates_and_complete_untouched_file() { + let mut bundle = rich_bundle(Layer::Oa); + *bundle.files.get_mut("main.arcadia").unwrap() = + bundle.files["main.arcadia"].replace("node n ", "node nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn "); + let source = authored(&bundle); + 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 = "renamed_f".into(); + let expected=SourceBundle {entry:"main.arcadia".into(),files:BTreeMap::from([ + ("a.arcadia".into(),"calculation renamed_f \"F\" id \"00000000-0000-0000-0000-000000000010\" {\ninput x id \"00000000-0000-0000-0000-000000000001\" : Number;\ninput y id \"00000000-0000-0000-0000-000000000012\" : Number;\nresult Number; body $x + $y;\n}\n".into()), + ("b.arcadia".into(),"calculation g \"G\" id \"00000000-0000-0000-0000-000000000011\" {\ninput x id \"00000000-0000-0000-0000-000000000001\" : Number;\ninput y id \"00000000-0000-0000-0000-000000000012\" : Number;\nresult Number; body $x - $y;\n}\n".into()), + ("main.arcadia".into(),"arcadia 8;\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\ninclude \"b.arcadia\"; include \"a.arcadia\";\nnode n id \"00000000-0000-0000-0000-000000000004\" { diagram @d; element @v; }\noa_property v \"Value\" id \"00000000-0000-0000-0000-000000000003\" { subject @w; type Number; base @renamed_f(y: 2, x: @g(x: 3, y: 4)) + @renamed_f(x: 5, y: 6); }\noa_activity w \"Work\" id \"00000000-0000-0000-0000-000000000002\" {}\ndiagram d \"Diagram\" id \"00000000-0000-0000-0000-000000000005\" { }\n\n}\n".into()), + ])}; + let plan = source.plan_rename(&desired).unwrap(); + assert_eq!(plan.output_bytes().unwrap(), 924); + let pending = plan.emit_changed_unverified(&bundle).unwrap(); + assert_eq!(pending.bundle, expected); + assert_eq!( + pending.retained, + Slots { + entries: 3, + occurrences: 0, + text: 966 + } + ); +} diff --git a/harness/analysis-mixed-emission-implementation-20260925.md b/harness/analysis-mixed-emission-implementation-20260925.md new file mode 100644 index 00000000..40adf9c2 --- /dev/null +++ b/harness/analysis-mixed-emission-implementation-20260925.md @@ -0,0 +1,23 @@ +# Private mixed emitted-unverified transition + +2026-09-25. Implementation candidate from `fa7a980418ef99d772e67c66c28dfa7b5181f875`, under the accepted root contract `harness/analysis-mixed-emission-contract-20260925.md` published at `6c0d9e80`. This note reports source work; parent owns compilation and runtime acceptance. + +`RenamePlan::into_unchanged` and `emit_changed_unverified` consume the plan. Both recheck the entire current bundle and reuse the complete original-coordinate edit validator; wrong-branch calls refuse. Unchanged returns the original admitted owner. Changed refuses every explicit carried guard, including zero-baseline mode, because dropping that guard cannot release its externally owned retained allocation. + +Changed emission computes O as one entry per output file and text equal to all final source bytes plus all owned keys plus entry bytes. It reserves O once against the existing live plan ledger B (which already contains R and M) before output copies. Each canonical file has one final String; scalar sizing walks borrow existing maps. Original-coordinate slices and replacement names are appended without parsing, decoding, normalization or sequential textual substitution. Length and complete-file checks precede the transition. The old names map, edits map and entire source witness explicitly drop before the opaque PendingRename is created. Caller-owned original source and desired Project remain external. Pending owns the bundle, exact policies, O, original expression count E and desired borrow; it has no extraction, parser/adoption or accepted-result API. E is provenance, not a proven equality bound on arbitrary future faulty output. + +The split API avoids a common large enum, callback framework, heap indirection or lint suppression. No producer hooks, carried constructor, semantic verification, public source8 dispatch, persistence or history behavior changed. Original stack controls and inherited failure/formatting dispositions are not edited or claimed repaired. + +## Source checks and authored tests + +Independent source reviewer inspected production before tests completed. Direct targeted rustfmt only; no Cargo, executable tests or compiler run by the implementer. Eleven new tests are selected by `emission`: ten in the new emission child and one appended to carried tests. + +- Actual predecessor-compatible E entry/text caps 17/114 versus 16/114 and 17/113, and H caps 29/216 versus 28/216 and 29/215; expected pending O values (1,0,105) and (3,0,183), complete literal bytes and source/entry/key ownership. +- No-op identity, usage, numeric work, warnings and retained ledger below copy-overlap byte ceilings; desired lifetime ends while unchanged owner survives. +- Both wrong branches, touched/untouched stale bytes, entry/path/file-set changes, equal independently owned current input, and selected reused edit-validator failures. Checked-add overflow is labeled arithmetic fault injection. +- All exact Limits/ProjectLimits fields and a pending result escaping the original source-bundle scope. +- Rich OA/SA global/scoped swaps with repeated/reordered labels, independently authored whole files in LF and Unicode/CRLF variants; four-file cross-family swap with an untouched comment file; full ordinary category output preserving prose, commented references and historical UUID; combined growth/shrinkage with an unchanged file. +- Actual retained and borrowed-zero carried sources: changed refuses; no-op preserves the original guard through a subsequent changed refusal. +- Scoped 128 KiB worker success/no-op/refusal and local drops, with parser prerequisites constructed normally. The same test also moves complete deep 128-node and wide 129-node owners into workers for changed emission and unchanged extraction/drop, asserting E and whole output bytes; the isolated storage refusal uses the minimal fixture. Existing full-pipeline stack tests remain unchanged. + +Oracle source: independent emission and rename-plan reports published with the contract/evidence, not output obtained from this emitter. Pre-execution review corrected a policy fixture that exceeded its caller byte/reference ceiling. Raw-byte inspection corrected the category expected literal to preserve its two specific CRLF line endings among LF lines rather than converting the whole file. These were authoring corrections, not runtime failures. Parent gates must still establish compile, actual boundary behavior, whole bytes, unchanged full-suite dispositions, Clippy and formatting.