From ab960cbebbf865e095c8309febcc9c6208be68b9 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 04:46:41 +0300 Subject: [PATCH] feat(source): persist typed semantic reviews in schema five --- crates/oa-core/src/language.rs | 77 +++- .../oa-core/src/language/semantic_source.rs | 203 +++++++++ crates/oa-core/src/language_nesting_tests.rs | 6 +- crates/oa-core/src/migration_v2/README.md | 4 +- crates/oa-core/src/migration_v3/mod.rs | 8 +- crates/oa-core/src/migration_v4.rs | 117 +++++ crates/oa-core/src/persistence.rs | 7 +- crates/oa-core/src/source_files.rs | 2 +- crates/oa-core/src/source_project.rs | 56 ++- .../src/source_project/source_migration.rs | 8 +- .../tests/fixtures/v4-reviewed-prose.arcadia | 27 ++ .../tests/fixtures/v4-reviewed-prose.json | 51 +++ crates/oa-core/tests/language.rs | 7 +- crates/oa-core/tests/port_migration.rs | 6 +- crates/oa-core/tests/port_source.rs | 3 +- crates/oa-core/tests/r3_migration.rs | 169 +++++++ crates/oa-core/tests/r3_source.rs | 431 ++++++++++++++++++ crates/oa-core/tests/r6_migration.rs | 8 +- crates/oa-core/tests/r6_source.rs | 2 +- crates/oa-core/tests/source_files.rs | 12 +- .../oa-core/tests/source_identity_updates.rs | 2 +- crates/oa-core/tests/source_nested_updates.rs | 2 +- crates/oa-core/tests/source_project.rs | 50 +- harness/release-semantic-source-plan.md | 39 ++ 24 files changed, 1223 insertions(+), 74 deletions(-) create mode 100644 crates/oa-core/src/language/semantic_source.rs create mode 100644 crates/oa-core/src/migration_v4.rs create mode 100644 crates/oa-core/tests/fixtures/v4-reviewed-prose.arcadia create mode 100644 crates/oa-core/tests/fixtures/v4-reviewed-prose.json create mode 100644 crates/oa-core/tests/r3_migration.rs create mode 100644 crates/oa-core/tests/r3_source.rs create mode 100644 harness/release-semantic-source-plan.md diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 4e0b8d58..231823b6 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -1,4 +1,6 @@ -//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration. +//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration. +#[path = "language/semantic_source.rs"] +mod semantic_source; #[path = "migration_v2/source.rs"] mod v2_source; use crate::*; @@ -387,7 +389,7 @@ fn enum_field(key: &str) -> bool { ) } -/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never +/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never /// adopts a model and never overwrites an editor draft. pub fn parse(source: &str) -> Result> { parse_limited(source, ParseLimits::UNLIMITED) @@ -552,7 +554,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize { /// nested records are ordinary language fields; JSON documents are not embedded. pub fn format(project: &Project) -> String { let mut out = std::format!( - "arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n", + "arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n", project.alias, quote(&project.name), project.id, @@ -743,6 +745,10 @@ fn write_value( indent: usize, aliases: &BTreeMap, ) { + if key == "semantic" { + semantic_source::write(out, value, indent); + return; + } match value { Value::Null => out.push_str("none"), Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }), @@ -786,6 +792,7 @@ enum Ast { Literal(Value, usize), Word(String, usize), Reference(String, usize), + HistoricalUuid(Id, usize), Array(Vec, usize), Object(BTreeMap, usize), } @@ -795,12 +802,20 @@ impl Ast { Self::Literal(_, p) | Self::Word(_, p) | Self::Reference(_, p) + | Self::HistoricalUuid(_, p) | Self::Array(_, p) | Self::Object(_, p) => *p, } } fn resolve(&self, key: &str, aliases: &BTreeMap) -> Result { match self { + Self::HistoricalUuid(_, _) => Err(Error { + code: "invalid_source", + offset: self.offset(), + message: + "A uuid literal is allowed only in a typed historical semantic reference slot." + .into(), + }), Self::Word(word, offset) => { if !enum_field(key) { return Err(Error { @@ -968,6 +983,7 @@ pub(crate) struct EditValue { pub(crate) enum EditKind { Atom, Reference(String), + HistoricalUuid(Id), Record(BTreeMap), List(Vec), } @@ -1080,7 +1096,7 @@ fn edit_positions( let span = Span { start: ast.offset(), end: match ast { - Ast::Reference(..) => self.tokens[index + 1].span.end, + Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end, _ => self.ends[index], }, }; @@ -1090,6 +1106,7 @@ fn edit_positions( EditKind::List(items.iter().map(|item| self.value(item)).collect()) } Ast::Reference(alias, _) => EditKind::Reference(alias.clone()), + Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id), _ => EditKind::Atom, }; EditValue { span, kind } @@ -1270,6 +1287,16 @@ impl Parser<'_> { let token = self.current(); let offset = token.span.start; match self.text() { + "uuid" => { + self.cursor += 1; + let value = self.quoted()?; + let id = Id::parse_str(&value).map_err(|_| Error { + code: "invalid_source", + offset, + message: "Expected a UUID after uuid.".into(), + })?; + Ok(Ast::HistoricalUuid(id, offset)) + } "@" => { self.cursor += 1; Ok(Ast::Reference(self.identifier()?, offset)) @@ -1356,11 +1383,11 @@ impl Parser<'_> { fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result { let mut header = if entry { self.take("arcadia")?; - if !matches!(self.text(), "3" | "4") { + if !matches!(self.text(), "3" | "4" | "5") { return Err(self - .error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'.")); + .error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'.")); } - let schema_version = if self.text() == "3" { 3 } else { 4 }; + let schema_version = self.text().parse().expect("checked source version"); self.cursor += 1; self.take(";")?; let start = self.current().span.start; @@ -1557,7 +1584,12 @@ pub(crate) fn resolve_declarations( let mut fields = declaration .fields .iter() - .map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?))) + .map(|(k, v)| { + let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" { + semantic_source::resolve_baseline(v, aliases, project.schema_version)? + } else { v.resolve(k, aliases)? }; + Ok((k.clone(), value)) + }) .collect::, Error>>()?; let d = declaration; let conversion_error = |message: String| Error { @@ -1729,8 +1761,12 @@ pub(crate) fn resolve_declarations( let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) .map_err(&conversion_error)?; header(&mut record, d, true); - let trace = serde_json::from_value(Value::Object(record)) - .map_err(|e| conversion_error(e.to_string()))?; + let trace = if project.schema_version < 5 { + let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?; + Trace::from(old) + } else { + serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))? + }; project.traces.insert(d.id, trace); } } @@ -1801,6 +1837,22 @@ pub(crate) fn resolve_declarations( } return Ok(migrated); } + if project.schema_version == 4 { + return crate::persistence::v4::finish(project, limits).map_err(|error| { + ( + usize::MAX, + Error { + code: if matches!(error, crate::persistence::FileError::Limit(_)) { + "model_limit" + } else { + "migration_error" + }, + offset: 0, + message: error.to_string(), + }, + ) + }); + } limits.check_model(&project).map_err(|message| { ( usize::MAX, @@ -2595,3 +2647,8 @@ pub(crate) mod legacy { } } } + +/// Closed historical facet identity for the targeted source writer. +pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap) -> Option { + semantic_source::row_key(source, origins) +} diff --git a/crates/oa-core/src/language/semantic_source.rs b/crates/oa-core/src/language/semantic_source.rs new file mode 100644 index 00000000..b6382b91 --- /dev/null +++ b/crates/oa-core/src/language/semantic_source.rs @@ -0,0 +1,203 @@ +//! Typed historical source leaves; live alias resolution remains in the parent. +use super::*; +use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots}; + +fn error(ast: &Ast, message: impl Into) -> Error { + Error { + code: "invalid_source", + offset: ast.offset(), + message: message.into(), + } +} + +fn raw(ast: &Ast) -> Result { + Ok(match ast { + Ast::Literal(value, _) => value.clone(), + Ast::Word(value, _) => Value::String(value.clone()), + Ast::HistoricalUuid(value, _) => Value::String(value.to_string()), + Ast::Reference(_, _) => { + return Err(error( + ast, + "Historical semantic references require uuid \"...\", not @alias.", + )); + } + Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::>()?), + Ast::Object(fields, _) => Value::Object( + fields + .iter() + .map(|(key, value)| Ok((key.clone(), raw(value)?))) + .collect::>()?, + ), + }) +} + +fn validate_tokens( + ast: &Ast, + path: &mut Vec, + slots: &BTreeMap, HistoricalSourceKind>, +) -> Result<(), Error> { + match ast { + Ast::Object(fields, _) => { + for (key, value) in fields { + path.push(key.clone()); + validate_tokens(value, path, slots)?; + path.pop(); + } + } + Ast::Array(values, _) => { + for (index, value) in values.iter().enumerate() { + path.push(index.to_string()); + validate_tokens(value, path, slots)?; + path.pop(); + } + } + _ => match (slots.get(path), ast) { + (Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..)) + | (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {} + (Some(HistoricalSourceKind::Reference), _) => { + return Err(error( + ast, + "This historical reference requires uuid \"...\".", + )); + } + (Some(HistoricalSourceKind::Enum), _) => { + return Err(error( + ast, + "This typed historical enum requires an unquoted enum word.", + )); + } + (None, Ast::HistoricalUuid(..)) => { + return Err(error( + ast, + "A uuid literal is allowed only in a typed historical reference slot.", + )); + } + (None, Ast::Word(..)) => { + return Err(error( + ast, + "This historical text value requires a quoted string.", + )); + } + _ => {} + }, + } + Ok(()) +} + +pub(super) fn resolve_baseline( + ast: &Ast, + aliases: &BTreeMap, + version: u32, +) -> Result { + let Ast::Object(fields, _) = ast else { + return ast.resolve("baseline", aliases); + }; + let mut resolved = Map::new(); + for (key, value) in fields { + if key != "semantic" { + resolved.insert(key.clone(), value.resolve(key, aliases)?); + continue; + } + if version < 5 { + return Err(error( + value, + "Semantic trace baselines require schema/source5; the older baseline contains prose only.", + )); + } + if matches!(value, Ast::Literal(Value::Null, _)) { + resolved.insert(key.clone(), Value::Null); + continue; + } + let json = raw(value)?; + let baseline: SemanticBaseline = serde_json::from_value(json.clone()) + .map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?; + let slots = historical_source_slots(&baseline).into_iter().collect(); + validate_tokens(value, &mut vec![], &slots)?; + resolved.insert(key.clone(), json); + } + Ok(Value::Object(resolved)) +} + +pub(super) fn write(out: &mut String, value: &Value, indent: usize) { + if value.is_null() { + out.push_str("none"); + return; + } + let baseline: SemanticBaseline = + serde_json::from_value(value.clone()).expect("typed semantic baseline"); + let slots = historical_source_slots(&baseline).into_iter().collect(); + write_at(out, value, indent, &mut vec![], &slots); +} + +fn write_at( + out: &mut String, + value: &Value, + indent: usize, + path: &mut Vec, + slots: &BTreeMap, HistoricalSourceKind>, +) { + match value { + Value::Null => out.push_str("none"), + Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => out.push_str(&value.to_string()), + Value::String(value) => match slots.get(path) { + Some(HistoricalSourceKind::Reference) => { + out.push_str("uuid "); + out.push_str("e(value)); + } + Some(HistoricalSourceKind::Enum) => out.push_str(value), + None => out.push_str("e(value)), + }, + Value::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + out.push_str(", "); + } + path.push(index.to_string()); + write_at(out, value, indent, path, slots); + path.pop(); + } + out.push(']'); + } + Value::Object(fields) => { + out.push_str("{\n"); + for (key, value) in fields { + out.push_str(&" ".repeat(indent + 1)); + out.push_str(key); + out.push(' '); + path.push(key.clone()); + write_at(out, value, indent + 1, path, slots); + path.pop(); + out.push_str(";\n"); + } + out.push_str(&" ".repeat(indent)); + out.push('}'); + } + } +} + +/// Recognize only complete typed facet rows. Their closed selector, with real +/// identity provenance applied, owns comments when review rows reorder. +pub(super) fn row_key(source: &str, origins: &BTreeMap) -> Option { + let mut parser = Parser { + source, + tokens: strict_tokens(source).ok()?, + cursor: 0, + value_depth: 0, + limits: ParseLimits::UNLIMITED, + }; + let ast = parser.value().ok()?; + let facet = serde_json::from_value(raw(&ast).ok()?).ok()?; + let mut baseline = SemanticBaseline { + facets: vec![facet], + }; + crate::semantic::visit_semantic_references(&mut baseline, |id| { + if let Some(original) = origins.get(id) { + *id = *original; + } + }); + serde_json::to_string(&baseline.facets[0].selector()) + .ok() + .map(|key| format!("semantic:{key}")) +} diff --git a/crates/oa-core/src/language_nesting_tests.rs b/crates/oa-core/src/language_nesting_tests.rs index d68e3e26..cac29fe6 100644 --- a/crates/oa-core/src/language_nesting_tests.rs +++ b/crates/oa-core/src/language_nesting_tests.rs @@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu fn project_source(body: &str) -> String { format!( - "arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}" + "arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}" ) } @@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes() files: BTreeMap::from([ ( "project.arcadia".into(), - "arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(), + "arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(), ), ( "children.arcadia".into(), @@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() { #[test] fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() { - let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated"; + let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated"; let analysis = analyze(source); let ready = analysis .declarations diff --git a/crates/oa-core/src/migration_v2/README.md b/crates/oa-core/src/migration_v2/README.md index 31e41cf0..767b7a32 100644 --- a/crates/oa-core/src/migration_v2/README.md +++ b/crates/oa-core/src/migration_v2/README.md @@ -13,7 +13,9 @@ generic port/optional FE endpoint contracts therefore remain explicit. `adapter.rs` budgets generated data, validates V2 and transforms only the changed port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader -then performs the data-contract upgrade and validates schema 4. Serialization +then performs the data-contract upgrade into the strict schema 4 envelope. The +schema 4 review adapter preserves its prose-only trace baselines and returns +schema 5 with semantic review absent. Serialization between compatibility DTOs copies unchanged fields; original input bytes are never deserialized into the new `Project`. V1 normalization also constructs this V2 DTO before the shared stages. Combined generation stays within one budget. diff --git a/crates/oa-core/src/migration_v3/mod.rs b/crates/oa-core/src/migration_v3/mod.rs index 049429ee..2d1ef106 100644 --- a/crates/oa-core/src/migration_v3/mod.rs +++ b/crates/oa-core/src/migration_v3/mod.rs @@ -24,7 +24,7 @@ pub(crate) struct Project3 { #[serde(deserialize_with = "crate::unique_id_map")] pub artifact_links: BTreeMap, #[serde(deserialize_with = "crate::unique_id_map")] - pub traces: BTreeMap, + pub traces: BTreeMap, } #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -486,8 +486,10 @@ pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result, +} +impl From for Trace { + fn from(old: Trace4) -> Self { + Self { + id: old.id, + alias: old.alias, + name: old.name, + description: old.description, + oa: old.oa, + sa: old.sa, + kind: old.kind, + baseline: old.baseline.map(|baseline| TraceBaseline { + oa: baseline.oa, + sa: baseline.sa, + semantic: None, + }), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Project4 { + pub schema_version: u32, + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + #[serde(deserialize_with = "crate::unique_id_map")] + pub elements: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub relations: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub diagrams: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub artifact_links: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub traces: BTreeMap, +} +impl Project4 { + pub(crate) fn into_current(self) -> Result { + if self.schema_version != 4 { + return Err(FileError::Invalid("Expected schema_version 4.".into())); + } + Ok(Project { + schema_version: SCHEMA_VERSION, + id: self.id, + alias: self.alias, + name: self.name, + description: self.description, + elements: self.elements, + relations: self.relations, + diagrams: self.diagrams, + artifact_links: self.artifact_links, + traces: self + .traces + .into_iter() + .map(|(id, trace)| (id, trace.into())) + .collect(), + }) + } +} + +pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) { + report.semantic_defaults.extend( + project + .traces + .values() + .filter(|trace| trace.baseline.is_some()) + .map(|trace| SemanticDefault { + object: trace.id, + field: "baseline.semantic".into(), + description: "Original prose review preserved; semantic facets remain unreviewed." + .into(), + }), + ); +} + +pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result { + project.schema_version = SCHEMA_VERSION; + limits.check_model(&project).map_err(FileError::Limit)?; + let mut report = MigrationReport::data_upgrade(4); + review_defaults(&project, &mut report); + Ok(DecodeResult { + project, + migration: Some(report), + }) +} + +pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result { + finish(old.into_current()?, limits) +} diff --git a/crates/oa-core/src/persistence.rs b/crates/oa-core/src/persistence.rs index c63bcdbc..da2b40c4 100644 --- a/crates/oa-core/src/persistence.rs +++ b/crates/oa-core/src/persistence.rs @@ -7,6 +7,8 @@ pub(crate) mod v2; pub(crate) use v2::adapter::migrate as migrate_v2_limited; #[path = "migration_v3/mod.rs"] pub(crate) mod v3; +#[path = "migration_v4.rs"] +pub(crate) mod v4; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -148,13 +150,14 @@ pub fn decode_with_report_limited( } Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?, Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?, - Some(4) => DecodeResult { + Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?, + Some(5) => DecodeResult { project: serde_json::from_slice(bytes)?, migration: None, }, _ => { return Err(FileError::Invalid( - "Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(), + "Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(), )); } } diff --git a/crates/oa-core/src/source_files.rs b/crates/oa-core/src/source_files.rs index 68a5e7d2..4686559d 100644 --- a/crates/oa-core/src/source_files.rs +++ b/crates/oa-core/src/source_files.rs @@ -999,7 +999,7 @@ mod transaction_tests { let directory = tempfile::tempdir().unwrap(); fs::write( directory.path().join("project.arcadia"), - "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", ) .unwrap(); fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap(); diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 836ef60b..a8764f6c 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -1,6 +1,6 @@ //! Pure, bounded source projects. All includes resolve against supplied UTF-8 //! documents; this module never reads a filesystem, URL, or process directory. -use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation}; +use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation}; use language::{Diagnostic, ParseLimits, Span}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -280,7 +280,7 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec Result<(), Vec SourceBundle { @@ -736,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle { .push_str(&unindent(&declaration)); } let mut entry = format!( - "arcadia 4;\n{}{{\n description {};\n", + "arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n", project_header(project), quote(&project.description) ); @@ -926,6 +926,7 @@ pub fn update_bundle_with_identity_map( old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, + identity_origins: Some(&origins), }; let local = edits.entry(path.clone()).or_default(); update.header(&actual.header, &previous.header, &next.header, local); @@ -955,6 +956,7 @@ pub fn update_bundle_with_identity_map( old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, + identity_origins: Some(&origins), }; let mut local = Vec::new(); update.header( @@ -1126,6 +1128,7 @@ struct SyntaxUpdate<'a> { // Source3 migration only adds path qualification; the existing field identity // must keep its authored argument trivia during that one-time transformation. legacy_value_paths: bool, + identity_origins: Option<&'a BTreeMap>, } impl SyntaxUpdate<'_> { fn header( @@ -1221,6 +1224,18 @@ impl SyntaxUpdate<'_> { alias.clone(), )); } + (EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => { + let token = significant(&self.source[actual.span.start..actual.span.end]) + .pop() + .expect("historical UUID string"); + edits.push(( + Span { + start: actual.span.start + token.span.start, + end: actual.span.start + token.span.end, + }, + quote(&id.to_string()), + )); + } (EditKind::Atom, EditKind::Atom) => edits.push(( actual.span, self.after[after.span.start..after.span.end].into(), @@ -1345,11 +1360,27 @@ impl SyntaxUpdate<'_> { let previous = before.unwrap_or(&[]); let old_keys: Option> = previous .iter() - .map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths)) + .map(|item| { + item_key( + item, + self.before, + self.old_aliases, + self.legacy_value_paths, + None, + ) + }) .collect(); let new_keys: Option> = after .iter() - .map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths)) + .map(|item| { + item_key( + item, + self.after, + self.new_aliases, + self.legacy_value_paths, + self.identity_origins, + ) + }) .collect(); let keyed = old_keys.zip(new_keys).filter(|(old, new)| { old.iter().collect::>().len() == old.len() @@ -1457,9 +1488,22 @@ fn item_key( source: &str, aliases: &BTreeMap, legacy_value_paths: bool, + origins: Option<&BTreeMap>, ) -> Option { use language::EditKind; + if matches!(value.kind, EditKind::Record(_)) + && let Some(key) = language::semantic_row_key( + &source[value.span.start..value.span.end], + origins.unwrap_or(&BTreeMap::new()), + ) + { + return Some(key); + } match &value.kind { + EditKind::HistoricalUuid(id) => Some(format!( + "uuid:{}", + origins.and_then(|map| map.get(id)).unwrap_or(id) + )), EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")), EditKind::Atom => Some(format!( "literal:{}", diff --git a/crates/oa-core/src/source_project/source_migration.rs b/crates/oa-core/src/source_project/source_migration.rs index 9bc0418c..05ce86d1 100644 --- a/crates/oa-core/src/source_project/source_migration.rs +++ b/crates/oa-core/src/source_project/source_migration.rs @@ -1,4 +1,4 @@ -//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive. +//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive. use super::*; pub(super) fn upgrade( @@ -15,6 +15,7 @@ pub(super) fn upgrade( let changed: BTreeSet<_> = report .semantic_defaults .iter() + .filter(|default| default.field != "baseline.semantic") .map(|default| default.object) .collect(); let aliases: BTreeMap<_, _> = project @@ -61,6 +62,7 @@ pub(super) fn upgrade( old_aliases: &aliases, new_aliases: &aliases, legacy_value_paths: true, + identity_origins: None, }; update.value( &actual.fields, @@ -78,7 +80,7 @@ pub(super) fn upgrade( edits .entry(current.bundle.entry.clone()) .or_default() - .push((version, "4".into())); + .push((version, crate::SCHEMA_VERSION.to_string())); // New allocation relations are emitted into the allocating interface's // existing module, outside its lexical element body. Existing source never moves. let mut added: BTreeMap = BTreeMap::new(); @@ -117,7 +119,7 @@ pub(super) fn upgrade( &bundle.entry, Span::default(), "migration_source_mismatch", - "Targeted source3 upgrade did not reproduce the migrated project.", + "Targeted source upgrade did not reproduce the migrated project.", )]); } result.decoded.migration = Some(report.clone()); diff --git a/crates/oa-core/tests/fixtures/v4-reviewed-prose.arcadia b/crates/oa-core/tests/fixtures/v4-reviewed-prose.arcadia new file mode 100644 index 00000000..39d34004 --- /dev/null +++ b/crates/oa-core/tests/fixtures/v4-reviewed-prose.arcadia @@ -0,0 +1,27 @@ +// Frozen schema4 input: reviewed prose is deliberately different from live prose. +arcadia 4; +project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" { + description "Preserve the original review without inventing semantic history."; + oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" { + description "Unreviewed OA description"; + definition { Integer { minimum 0; maximum 100; }; }; + default { Integer 20; }; + unit "m"; + } + sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" { + description "Unreviewed SA description"; + definition { Real { minimum 0.0; maximum 100.0; }; }; + default { Real 20.0; }; + unit "ft"; + } + trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" { + description "The target unit and representation are intentional."; + oa @oa_dimension; + sa @sa_dimension; + kind Item; + baseline { + oa { name "Approved OA name"; description "Approved OA\nprose"; }; + sa { name "Approved SA name"; description "Approved SA prose"; }; + }; + } +} diff --git a/crates/oa-core/tests/fixtures/v4-reviewed-prose.json b/crates/oa-core/tests/fixtures/v4-reviewed-prose.json new file mode 100644 index 00000000..7b792aae --- /dev/null +++ b/crates/oa-core/tests/fixtures/v4-reviewed-prose.json @@ -0,0 +1,51 @@ +{ + "schema_version": 4, + "id": "40000000-0000-4000-8000-000000000001", + "alias": "reviewed_prose", + "name": "Reviewed dimensions", + "description": "Preserve the original review without inventing semantic history.", + "elements": { + "40000000-0000-4000-8000-000000000002": { + "id": "40000000-0000-4000-8000-000000000002", + "alias": "oa_dimension", + "name": "OA dimension now", + "description": "Unreviewed OA description", + "parent": null, + "body": {"Oa": {"Shared": {"DataType": { + "definition": {"Integer": {"minimum": 0, "maximum": 100}}, + "default": {"Integer": 20}, + "unit": "m" + }}}} + }, + "40000000-0000-4000-8000-000000000003": { + "id": "40000000-0000-4000-8000-000000000003", + "alias": "sa_dimension", + "name": "SA dimension now", + "description": "Unreviewed SA description", + "parent": null, + "body": {"Sa": {"Shared": {"DataType": { + "definition": {"Real": {"minimum": 0.0, "maximum": 100.0}}, + "default": {"Real": 20.0}, + "unit": "ft" + }}}} + } + }, + "relations": {}, + "diagrams": {}, + "artifact_links": {}, + "traces": { + "40000000-0000-4000-8000-000000000004": { + "id": "40000000-0000-4000-8000-000000000004", + "alias": "dimension_review", + "name": "Dimension rationale", + "description": "The target unit and representation are intentional.", + "oa": "40000000-0000-4000-8000-000000000002", + "sa": "40000000-0000-4000-8000-000000000003", + "kind": "Item", + "baseline": { + "oa": {"name": "Approved OA name", "description": "Approved OA\nprose"}, + "sa": {"name": "Approved SA name", "description": "Approved SA prose"} + } + } + } +} diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs index 7ef526e3..389cafe9 100644 --- a/crates/oa-core/tests/language.rs +++ b/crates/oa-core/tests/language.rs @@ -491,7 +491,7 @@ fn authoring_defaults_and_forward_references_remain_strict() { #[test] fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() { for (source, expected) in [ - ("arcadia 5; project mission \"x\" {}", "Unsupported schema"), + ("arcadia 6; project mission \"x\" {}", "Unsupported schema"), ( "arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }", "Unknown field", @@ -795,7 +795,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi assert_eq!(from_source, from_json); let original: serde_json::Value = serde_json::from_slice(old_json).unwrap(); let mut retained = serde_json::to_value(&from_json).unwrap(); - assert_eq!(retained["schema_version"], 4); + assert_eq!(retained["schema_version"], 5); retained["schema_version"] = 2.into(); let allocations: Vec<_> = from_json .relations @@ -991,7 +991,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics() assert_eq!(from_text, from_json); assert_eq!(read(text), from_text); assert_eq!(persistence::decode(json).unwrap(), from_json); - assert_eq!(from_text.schema_version, 4); + assert_eq!(from_text.schema_version, 5); assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa)); let raw: serde_json::Value = serde_json::from_slice(json).unwrap(); for (id, record) in raw["elements"].as_object().unwrap() { @@ -1193,6 +1193,7 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() { if code.trim_start().starts_with("arcadia 2;") || code.trim_start().starts_with("arcadia 3;") || code.trim_start().starts_with("arcadia 4;") + || code.trim_start().starts_with("arcadia 5;") { read(&code); examples += 1; diff --git a/crates/oa-core/tests/port_migration.rs b/crates/oa-core/tests/port_migration.rs index 3684cb96..9146fdd4 100644 --- a/crates/oa-core/tests/port_migration.rs +++ b/crates/oa-core/tests/port_migration.rs @@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() { assert_eq!(decoded.project, from_source.project); assert_eq!(decoded.migration, from_source.migration); let report = decoded.migration.unwrap(); - assert_eq!((report.from_schema, report.to_schema), (2, 4)); + assert_eq!((report.from_schema, report.to_schema), (2, 5)); assert_eq!(report.preserved_ports.len(), 4); assert!(report.generated_endpoints.is_empty()); assert_eq!(report.orientation_defaults.len(), 1); @@ -144,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() { } } let canonical = language::format(&first.project); - assert!(canonical.starts_with("arcadia 4;")); + assert!(canonical.starts_with("arcadia 5;")); let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap(); assert!(reopened.migration.is_none()); assert_eq!(reopened.project, first.project); @@ -274,7 +274,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() { assert!(error.contains("V2") && error.contains(&fe), "{error}"); let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json"); let result = persistence::decode_with_report(v1).unwrap(); - assert_eq!(result.project.schema_version, 4); + assert_eq!(result.project.schema_version, 5); assert_eq!(result.migration.unwrap().from_schema, 1); assert_eq!(persistence::decode(v1).unwrap(), result.project); } diff --git a/crates/oa-core/tests/port_source.rs b/crates/oa-core/tests/port_source.rs index e42ed316..cc064586 100644 --- a/crates/oa-core/tests/port_source.rs +++ b/crates/oa-core/tests/port_source.rs @@ -29,7 +29,8 @@ project typed_ports "Typed ports" { #[test] fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() { let decoded = language::parse_with_report(SOURCE).unwrap(); - assert!(decoded.migration.is_none()); + let migration = decoded.migration.as_ref().unwrap(); + assert_eq!((migration.from_schema, migration.to_schema), (4, 5)); let canonical = language::format(&decoded.project); for field in [ "incoming_items [@data]", diff --git a/crates/oa-core/tests/r3_migration.rs b/crates/oa-core/tests/r3_migration.rs new file mode 100644 index 00000000..df6efabf --- /dev/null +++ b/crates/oa-core/tests/r3_migration.rs @@ -0,0 +1,169 @@ +use oa_core::{language, persistence, source_project::*, *}; +use serde_json::Value; +use std::collections::BTreeMap; + +const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json"); +const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia"); + +#[test] +fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() { + let json = persistence::decode_with_report(JSON4).unwrap(); + let source = language::parse_with_report(SOURCE4).unwrap(); + assert_eq!(json.project, source.project); + for decoded in [json, source] { + assert_eq!(decoded.project.schema_version, 5); + let report = decoded.migration.unwrap(); + assert_eq!((report.from_schema, report.to_schema), (4, 5)); + let trace = decoded.project.traces.values().next().unwrap(); + let baseline = trace.baseline.as_ref().unwrap(); + assert!(baseline.semantic.is_none()); + assert_eq!(baseline.oa.name, "Approved OA name"); + assert_eq!(baseline.oa.description, "Approved OA\nprose"); + assert_eq!(baseline.sa.name, "Approved SA name"); + assert_ne!(baseline.oa.name, decoded.project.named(trace.oa)); + let mut expected: Value = serde_json::from_slice(JSON4).unwrap(); + expected["schema_version"] = 5.into(); + assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected); + let reopened = + persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap()) + .unwrap(); + assert!(reopened.migration.is_none()); + assert_eq!(reopened.project, decoded.project); + } +} + +#[test] +fn older_headers_cannot_admit_new_semantic_baselines() { + let mut value: Value = serde_json::from_slice(JSON4).unwrap(); + let trace = value["traces"] + .as_object_mut() + .unwrap() + .values_mut() + .next() + .unwrap(); + trace["baseline"]["semantic"] = serde_json::json!({"facets": []}); + for version in [3, 4] { + value["schema_version"] = version.into(); + let error = persistence::decode(&serde_json::to_vec(&value).unwrap()) + .unwrap_err() + .to_string(); + assert!(error.contains("semantic"), "{error}"); + } + let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };"); + assert!( + language::parse(&source) + .unwrap_err() + .iter() + .any(|d| d.message.contains("require schema/source5")) + ); + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), source)]), + }; + assert!( + compile_bundle(&bundle, ProjectLimits::default()) + .unwrap_err() + .iter() + .any(|d| d.diagnostic.message.contains("require schema/source5")) + ); +} + +#[test] +fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() { + let json = std::str::from_utf8(JSON4).unwrap(); + let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {"); + assert!( + persistence::decode(duplicate.as_bytes()) + .unwrap_err() + .to_string() + .contains("duplicate field") + ); + let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,"); + assert!( + persistence::decode(unknown.as_bytes()) + .unwrap_err() + .to_string() + .contains("unknown field") + ); +} + +#[test] +fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() { + let start = SOURCE4.find(" oa_data_type").unwrap(); + let end = SOURCE4.rfind('}').unwrap(); + let entry = format!( + "{} include \"contracts.arcadia\";\n}}\n", + &SOURCE4[..start] + ); + let mut module = + SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale"); + module.push_str( + r#" + oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" { + definition { Record { supertype none; }; }; + // This field remains inside its authored record. + oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; } + } +"#, + ); + let bundle = SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), entry.clone()), + ("contracts.arcadia".into(), module.clone()), + ]), + }; + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + assert_eq!(compiled.bundle.files["contracts.arcadia"], module); + assert_eq!( + compiled.bundle.files["project.arcadia"], + entry.replacen("arcadia 4;", "arcadia 5;", 1) + ); + assert_eq!( + compiled.index.lexical_owners + [&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()], + Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap() + ); + let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap(); + assert!(reopened.decoded.migration.is_none()); + assert_eq!(reopened.bundle, compiled.bundle); + assert_eq!(reopened.decoded.project, compiled.decoded.project); + assert_eq!( + bundle.files["project.arcadia"], entry, + "caller draft is unchanged" + ); +} + +#[test] +fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() { + for (version, bytes) in [ + ( + 1, + include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(), + ), + ( + 2, + include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(), + ), + ( + 3, + include_bytes!("fixtures/v3-data-contract.json").as_slice(), + ), + ] { + let decoded = persistence::decode_with_report(bytes).unwrap(); + let report = decoded.migration.unwrap(); + assert_eq!((report.from_schema, report.to_schema), (version, 5)); + assert_eq!(decoded.project.schema_version, 5); + assert!( + decoded + .project + .traces + .values() + .all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none())) + ); + assert_eq!( + language::parse(&language::format(&decoded.project)).unwrap(), + decoded.project + ); + } +} diff --git a/crates/oa-core/tests/r3_source.rs b/crates/oa-core/tests/r3_source.rs new file mode 100644 index 00000000..21cde40c --- /dev/null +++ b/crates/oa-core/tests/r3_source.rs @@ -0,0 +1,431 @@ +use oa_core::{language, persistence, semantic::*, source_project::*, *}; +use std::collections::BTreeMap; + +fn id(n: u128) -> Id { + Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n) +} + +fn reviewed(oa: T, sa: T) -> Reviewed { + Reviewed { + oa: Observed::Value(oa), + sa: Observed::Value(sa), + } +} + +fn project(facets: Vec) -> Project { + let mut project = + persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap(); + project + .traces + .get_mut(&id(4)) + .unwrap() + .baseline + .as_mut() + .unwrap() + .semantic = Some(SemanticBaseline { facets }); + project +} + +fn facets(project: &mut Project) -> &mut Vec { + &mut project + .traces + .get_mut(&id(4)) + .unwrap() + .baseline + .as_mut() + .unwrap() + .semantic + .as_mut() + .unwrap() + .facets +} + +fn compile(source: String) -> CompiledSourceProject { + compile_bundle( + &SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([("project.arcadia".into(), source)]), + }, + ProjectLimits::default(), + ) + .unwrap() +} + +#[test] +fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() { + let row = MessageValue { + path: vec![id(91), id(92)], + field: id(93), + expression: "x + 1".into(), + }; + let model = project(vec![ + FacetReview::Data(DataReview::Constructor(reviewed( + DataConstructor::Integer, + DataConstructor::Real, + ))), + FacetReview::Data(DataReview::IntegerMinimum(Reviewed { + oa: Observed::Value(None), + sa: Observed::NotApplicable, + })), + FacetReview::Data(DataReview::RealMaximum(Reviewed { + oa: Observed::NotApplicable, + sa: Observed::Value(Some(1.5)), + })), + FacetReview::Field(FieldReview::Type(reviewed( + TypeRef::Named(id(90)), + TypeRef::Boolean, + ))), + FacetReview::Field(FieldReview::Default(reviewed( + Some(vec![DataValue::String(id(90).to_string())]), + None, + ))), + FacetReview::Member { + collection: ReferenceCollection::Steps, + keys: KeyPair { + oa: Some(id(91)), + sa: None, + }, + review: reviewed(true, false), + }, + FacetReview::Order { + collection: OrderedCollection::Steps, + review: reviewed(vec![id(91), id(92)], vec![]), + }, + FacetReview::Annotation { + keys: KeyPair { + oa: Some(AnnotationKey::from(&row)), + sa: None, + }, + review: reviewed(Some(row), None), + }, + FacetReview::Binding { + keys: KeyPair { + oa: Some(id(94)), + sa: None, + }, + review: reviewed( + Some(LifelineBinding { + referenced: id(94), + local: id(95), + }), + None, + ), + }, + FacetReview::Literal { + keys: KeyPair { + oa: Some(id(90).to_string()), + sa: None, + }, + review: reviewed(true, false), + }, + FacetReview::Artifact { + keys: KeyPair { + oa: Some(id(96)), + sa: None, + }, + review: ArtifactReview::Relation(reviewed( + ArtifactRelation::References, + ArtifactRelation::References, + )), + }, + ]); + assert!(!model.contains(id(90))); + let source = language::format(&model); + assert!(source.starts_with("arcadia 5;")); + assert!( + source.contains(&format!("Named uuid \"{}\"", id(90))), + "{source}" + ); + assert!(source.contains("sa NotApplicable;")); + assert!(source.contains("Value none;")); + assert!(source.contains(&format!("String \"{}\"", id(90)))); + assert_eq!(language::parse(&source).unwrap(), model); + assert_eq!(compile(source).decoded.project, model); + assert_eq!( + persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(), + model + ); +} + +#[test] +fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() { + let model = project(vec![ + FacetReview::Field(FieldReview::Type(reviewed( + TypeRef::Named(id(2)), + TypeRef::Boolean, + ))), + FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))), + ]); + let source = language::format(&model); + let reference = format!("Named uuid \"{}\"", id(2)); + for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] { + let invalid = source.replace(&reference, &replacement); + assert!(language::parse(&invalid).is_err(), "{invalid}"); + assert!( + compile_bundle( + &SourceBundle { + entry: "p.arcadia".into(), + files: BTreeMap::from([("p.arcadia".into(), invalid)]) + }, + ProjectLimits::default() + ) + .is_err() + ); + } + let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2))); + assert!(language::parse(&invalid_text).is_err()); + let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";"); + assert!(language::parse(&invalid_enum).is_err()); + let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2))); + assert!(language::parse(&invalid_live).is_err()); + let invalid_prose = source.replace( + "name \"Approved OA name\";", + &format!("name uuid \"{}\";", id(2)), + ); + assert!(language::parse(&invalid_prose).is_err()); + // `uuid` remains an ordinary legal declaration alias after @. + assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok()); +} + +#[test] +fn semantic_values_remain_closed_typed_records_in_both_formats() { + let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum( + reviewed(Some(0), None), + ))]); + let source = language::format(&model); + for invalid in [ + source.replace("IntegerMinimum", "UnknownFacet"), + source.replace("Value 0;", "Value \"0\";"), + source.replace("facets [", "unexpected true; facets ["), + ] { + assert!(language::parse(&invalid).is_err(), "{invalid}"); + } + let mut json = serde_json::to_value(&model).unwrap(); + json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]}); + assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err()); +} + +#[test] +fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() { + let row = MessageValue { + path: vec![id(2)], + field: id(90), + expression: id(2).to_string(), + }; + let model = project(vec![ + FacetReview::Annotation { + keys: KeyPair { + oa: Some(AnnotationKey::from(&row)), + sa: None, + }, + review: reviewed(Some(row), None), + }, + FacetReview::Literal { + keys: KeyPair { + oa: Some(id(2).to_string()), + sa: None, + }, + review: reviewed(true, false), + }, + FacetReview::Field(FieldReview::Type(reviewed( + TypeRef::Named(id(2)), + TypeRef::Boolean, + ))), + ]); + let source = language::format(&model) + .replace("Annotation {", "Annotation { // annotation rationale") + .replace("Literal {", "Literal { // literal rationale") + .replace("Named uuid ", "Named uuid // historical type rationale\n"); + let current = compile(source); + let mut prepared = prepare_command( + ¤t.decoded.project, + &Command::Reidentify(id(2), id(20)), + ) + .unwrap(); + facets(&mut prepared.project).reverse(); + let updated = update_bundle_with_identity_map( + ¤t, + &prepared.project, + &prepared.identity_map, + ProjectLimits::default(), + ) + .unwrap(); + let text = &updated.bundle.files["project.arcadia"]; + assert_eq!(updated.decoded.project, prepared.project); + assert!( + text.contains(&format!( + "Named uuid // historical type rationale\n\"{}\"", + id(20) + )), + "{text}" + ); + let type_note = text.find("// historical type rationale").unwrap(); + let literal_note = text.find("// literal rationale").unwrap(); + let annotation_note = text.find("// annotation rationale").unwrap(); + assert!( + type_note < literal_note && literal_note < annotation_note, + "{text}" + ); + assert!(text.contains(&format!("expression \"{}\"", id(2)))); + let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else { + panic!() + }; + assert_eq!( + keys.oa, + Some(id(2).to_string()), + "UUID-shaped literal keys are text" + ); + assert_eq!( + compile_bundle(&updated.bundle, ProjectLimits::default()) + .unwrap() + .bundle, + updated.bundle + ); +} + +#[test] +fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() { + let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed( + TypeRef::Named(id(90)), + TypeRef::Boolean, + )))]); + let mut former_type = model.elements[&id(2)].clone(); + former_type.id = id(90); + former_type.alias = "former_type".into(); + model.elements.insert(id(90), former_type); + let current = compile(language::format(&model)); + let prepared = prepare_command(¤t.decoded.project, &Command::Delete(id(90))).unwrap(); + let updated = update_bundle_with_identity_map( + ¤t, + &prepared.project, + &prepared.identity_map, + ProjectLimits::default(), + ) + .unwrap(); + let source = &updated.bundle.files["project.arcadia"]; + assert!(!updated.decoded.project.contains(id(90))); + assert!(!source.contains("@former_type")); + assert!(source.contains(&format!("Named uuid \"{}\"", id(90)))); + assert_eq!(updated.decoded.project.traces, model.traces); + assert_eq!(language::parse(source).unwrap(), prepared.project); +} + +#[test] +fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() { + use oa_core::source_files::{load_project, save_project}; + let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed( + TypeRef::Named(id(90)), + TypeRef::Boolean, + )))]); + let source = language::format(&model); + let trace_at = source.find(" trace ").unwrap(); + let end = source.rfind('}').unwrap(); + let entry = format!( + "{} include \"reviews.arcadia\";\n}}\n", + &source[..trace_at] + ); + let review = + source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n"); + let directory = tempfile::tempdir().unwrap(); + let entry_path = directory.path().join("project.arcadia"); + std::fs::write(&entry_path, &entry).unwrap(); + std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap(); + let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap(); + let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap(); + assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia"); + let mut changed = compiled.decoded.project.clone(); + let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else { + panic!() + }; + value.sa = Observed::Value(TypeRef::String); + let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap(); + assert_eq!(updated.bundle.files["project.arcadia"], entry); + let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap(); + let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap(); + assert_eq!(saved.bundle, reopened.bundle); + assert_eq!( + compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default()) + .unwrap() + .decoded + .project, + changed + ); + assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry); + assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained")); + assert!(!changed.contains(id(90))); +} + +#[test] +fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() { + use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest}; + let model = project(vec![]); + let current = compile(language::format(&model).replace( + "baseline {", + "baseline { // prior prose review is independent", + )); + let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]); + transition.mappings.push(TransitionMapping { + source: id(2), + target: Some(id(3)), + target_kind: None, + }); + let mut request = SemanticTransitionRequest::new(transition); + request.reviews.push(SemanticReviewRequest { + source: id(2), + resolutions: vec![ + FacetResolution { + facet: FacetSelector::Data(DataFacet::Unit), + decision: SemanticDecision::KeepTarget, + }, + FacetResolution { + facet: FacetSelector::Header(HeaderFacet::Name), + decision: SemanticDecision::TakeSource, + }, + ], + reset: vec![], + }); + let plan = plan_semantic_transition(¤t.decoded.project, request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let prepared = prepare_semantic_transition(¤t.decoded.project, &plan).unwrap(); + let updated = update_bundle_with_identity_map( + ¤t, + &prepared.project, + &prepared.identity_map, + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!( + updated.decoded.project.elements[&id(3)].name, + "OA dimension now" + ); + assert!( + matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft") + ); + assert!( + updated.bundle.files["project.arcadia"].contains("// prior prose review is independent") + ); + let baseline = updated.decoded.project.traces[&id(4)] + .baseline + .as_ref() + .unwrap(); + assert_eq!(baseline.oa.name, "Approved OA name"); + assert!( + baseline + .semantic + .as_ref() + .unwrap() + .facets + .contains(&FacetReview::Data(DataReview::Unit(reviewed( + "m".into(), + "ft".into() + )))) + ); + assert_eq!( + language::parse(&language::format(&updated.decoded.project)).unwrap(), + prepared.project + ); + assert_eq!( + persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(), + prepared.project + ); +} diff --git a/crates/oa-core/tests/r6_migration.rs b/crates/oa-core/tests/r6_migration.rs index 815f0855..886cf173 100644 --- a/crates/oa-core/tests/r6_migration.rs +++ b/crates/oa-core/tests/r6_migration.rs @@ -21,7 +21,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() assert_eq!(decoded.migration, source.migration); let project = &decoded.project; let report = decoded.migration.as_ref().unwrap(); - assert_eq!((report.from_schema, report.to_schema), (3, 4)); + assert_eq!((report.from_schema, report.to_schema), (3, 5)); assert_eq!(project.identities().len(), 19); assert!(report.preserved_ports.is_empty()); assert!(report.generated_endpoints.is_empty()); @@ -71,7 +71,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() .any(|issue| issue.code == "message_value_ambiguous_path") ); let canonical = language::format(project); - assert!(canonical.starts_with("arcadia 4;")); + assert!(canonical.starts_with("arcadia 5;")); let reopened = language::parse_with_report(&canonical).unwrap(); assert_eq!(&reopened.project, project); assert!(reopened.migration.is_none()); @@ -154,12 +154,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() { language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap(); assert_eq!(v2.project, text.project); assert_eq!(v2.migration, text.migration); - assert_eq!(v2.migration.unwrap().to_schema, 4); + assert_eq!(v2.migration.unwrap().to_schema, 5); let v1 = language::parse_with_report( "arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}", ) .unwrap(); - assert_eq!(v1.project.schema_version, 4); + assert_eq!(v1.project.schema_version, 5); assert_eq!(v1.migration.unwrap().from_schema, 1); } diff --git a/crates/oa-core/tests/r6_source.rs b/crates/oa-core/tests/r6_source.rs index 4402a68e..0335b26a 100644 --- a/crates/oa-core/tests/r6_source.rs +++ b/crates/oa-core/tests/r6_source.rs @@ -26,7 +26,7 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i }; let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); assert_eq!(compiled.bundle.files.len(), 2); - assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;")); + assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;")); assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";")); for text in [ "// Contracts stay in their original module.", diff --git a/crates/oa-core/tests/source_files.rs b/crates/oa-core/tests/source_files.rs index a887d436..8749684a 100644 --- a/crates/oa-core/tests/source_files.rs +++ b/crates/oa-core/tests/source_files.rs @@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir { let directory = tempfile::tempdir().unwrap(); fs::write( directory.path().join("project.arcadia"), - "arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n", + "arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n", ) .unwrap(); fs::create_dir(directory.path().join("parts")).unwrap(); @@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() { fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() { let directory = setup(); let entry = directory.path().join("project.arcadia"); - fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap(); + fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap(); fs::write( directory.path().join("parts/a.arcadia"), "oa_activity a \"A\" { invalid", @@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() { let overlays = BTreeMap::from([ ( "project.arcadia".into(), - "arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(), + "arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(), ), ( "parts/a.arcadia".into(), @@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions( files: BTreeMap::from([ ( "project.arcadia".into(), - "arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(), + "arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(), ), ("child.arcadia".into(), "oa_activity a \"A\" {}".into()), ]), @@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() { .unwrap(); fs::write( directory.path().join("project.arcadia"), - "arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }", ) .unwrap(); assert!(matches!( @@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() { std::os::unix::fs::symlink(&destination, &link).unwrap(); fs::write( directory.path().join("project.arcadia"), - "arcadia 4; project p \"P\" { include \"alias.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"alias.arcadia\"; }", ) .unwrap(); let loaded = load_project( diff --git a/crates/oa-core/tests/source_identity_updates.rs b/crates/oa-core/tests/source_identity_updates.rs index 2e725499..757b06ba 100644 --- a/crates/oa-core/tests/source_identity_updates.rs +++ b/crates/oa-core/tests/source_identity_updates.rs @@ -6,7 +6,7 @@ fn source() -> CompiledSourceProject { &SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ - ("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()), + ("project.arcadia".into(), "arcadia 5; 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_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()), diff --git a/crates/oa-core/tests/source_nested_updates.rs b/crates/oa-core/tests/source_nested_updates.rs index fd712dc2..0e706df0 100644 --- a/crates/oa-core/tests/source_nested_updates.rs +++ b/crates/oa-core/tests/source_nested_updates.rs @@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject { compile_bundle(&SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ - ("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()), + ("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()), ("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()), ]), }, ProjectLimits::default()).unwrap() diff --git a/crates/oa-core/tests/source_project.rs b/crates/oa-core/tests/source_project.rs index 0fae3684..7c1bc599 100644 --- a/crates/oa-core/tests/source_project.rs +++ b/crates/oa-core/tests/source_project.rs @@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id { #[test] fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() { let input = bundle( - "arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", + "arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", &[ ( "work.arcadia", @@ -95,7 +95,7 @@ diagram review "Review" { #[test] fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() { let mut input = bundle( - "arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", + "arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", &[ ( "work.arcadia", @@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() { fn typed() -> SourceBundle { bundle( - "arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}", + "arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}", &[ ( "exchanges.arcadia", @@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() { #[test] fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() { let input = bundle( - "arcadia 4; project p \"P\" { include \"work.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"work.arcadia\"; }", &[( "work.arcadia", "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n", @@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() { #[test] fn source_errors_keep_exact_fragment_unicode_positions() { let input = bundle( - "arcadia 4; project p \"P\" { include \"child.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"child.arcadia\"; }", &[( "child.arcadia", "// Пример\nsa_function child \"航海\" { parent @missing; }", @@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() { ), ] { let input = bundle( - "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", &[("a.arcadia", left), ("b.arcadia", right)], ); let error = compile_bundle(&input, ProjectLimits::default()) @@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() { assert_eq!(error.related[0].path, "a.arcadia"); } let input = bundle( - "arcadia 4; project p \"P\" { include \"child.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"child.arcadia\"; }", &[("child.arcadia", "oa_activity p \"Collision\" {}")], ); let error = compile_bundle(&input, ProjectLimits::default()) @@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() { #[test] fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() { let mut input = bundle( - "arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }", &[ ("sub/a.arcadia", "include \"../shared.arcadia\";"), ("sub/b.arcadia", "include \"../shared.arcadia\";"), @@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() { ); assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err()); let input = bundle( - "arcadia 4; project p \"P\" {}", + "arcadia 5; project p \"P\" {}", &[("A.arcadia", ""), ("a.arcadia", "")], ); assert_eq!( @@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() { #[test] fn project_admission_is_aggregate_and_precedes_global_resolution() { let input = bundle( - "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", &[ ("a.arcadia", "oa_activity one \"One\" { parent @missing; }"), ("b.arcadia", "oa_activity two \"Two\" {}"), @@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() { #[test] fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() { - let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }"; + let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }"; assert!( language::parse(source).unwrap_err()[0] .message @@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() { #[test] fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() { let input = bundle( - "arcadia 4;project p\"P\"{include \"child.arcadia\";}", + "arcadia 5;project p\"P\"{include \"child.arcadia\";}", &[( "child.arcadia", "oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}", @@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind #[test] fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() { let input = bundle( - "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }", &[("a.arcadia", "")], ); assert_eq!( @@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg "project_limit" ); let input = bundle( - "arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", &[ ( "a.arcadia", @@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() { &source[span.start..span.end], "include \"../shared.arcadia\";" ); - let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }"; + let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }"; assert_eq!( inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0] .target, @@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec { #[test] fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() { - let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n"; + let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n"; let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n"; let current = compile(&bundle(entry, &[("behavior.arcadia", source)])); let mut project = current.decoded.project.clone(); @@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s #[test] fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() { let input = bundle( - "arcadia 4; project p \"P\" { include \"work.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"work.arcadia\"; }", &[( "work.arcadia", "oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n", @@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after #[test] fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() { let input = bundle( - "arcadia 4; project p \"P\" { include \"work.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"work.arcadia\"; }", &[( "work.arcadia", "oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n", @@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields #[test] fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() { let input = bundle( - "arcadia 4; project p \"P\" { include \"item.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"item.arcadia\"; }", &[( "item.arcadia", "oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n", @@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec { #[test] fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() { let input = bundle( - "arcadia 4; project p \"P\" { include \"event.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"event.arcadia\"; }", &[( "event.arcadia", "oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n", @@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment #[test] fn scalar_record_constructor_changes_keep_comments_inside_removed_values() { let input = bundle( - "arcadia 4; project p \"P\" { include \"ports.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"ports.arcadia\"; }", &[( "ports.arcadia", "sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n", @@ -1017,7 +1017,7 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() { #[test] fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() { let input = bundle( - "arcadia 4; project p \"P\" { include \"types.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"types.arcadia\"; }", &[( "types.arcadia", "sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n", @@ -1052,7 +1052,7 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident #[test] fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() { let input = bundle( - "arcadia 4; project p \"P\" { include \"types.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"types.arcadia\"; }", &[( "types.arcadia", "sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n", @@ -1104,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange { }; }; } "#; let current = compile(&bundle( - "arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }", &[("conversation.arcadia", source)], )); let mut project = current.decoded.project.clone(); @@ -1153,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange { #[test] fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() { let input = bundle( - "arcadia 4; project p \"P\" { include \"types.arcadia\"; }", + "arcadia 5; project p \"P\" { include \"types.arcadia\"; }", &[( "types.arcadia", "oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n", diff --git a/harness/release-semantic-source-plan.md b/harness/release-semantic-source-plan.md new file mode 100644 index 00000000..ccb142d1 --- /dev/null +++ b/harness/release-semantic-source-plan.md @@ -0,0 +1,39 @@ +# R3 source and migration integration + +Status: source/persistence integration implemented and verified, 2026-09-06. Worktree: `D:/CodexBuilds/Capella2-semantic-review`, branch `codex/semantic-review`, base `ab661f2`. This plan owns only source/persistence integration; it does not claim overall R3 acceptance. The adapter uses the published core facet types and typed historical slot visitor. + +## Ownership and fixed boundary + +The source worker owns `persistence.rs`, a new strict schema4 adapter, `language.rs` and its source submodules, `source_project`/`source_files`, and focused source/migration tests. Core owns `model.rs`, `lib.rs`, the semantic review module, transition preparation, historical-reference remapping and semantic admission. Desktop/server integration belongs to the lead. Cargo target ownership is coordinated before any invocation. + +The stable outer shape is `TraceBaseline { oa, sa, semantic: Option }`: existing prose pairs remain exact; absent semantic review means Unreviewed. SemanticBaseline contains closed, self-tagged typed reviewed pairs. Whole typed records exist only as review candidates, never as persisted carriers, masks or checkpoints. `TraceBaseline` and `Trace` become PartialEq because reviewed data includes finite real values. + +## Explicit compatibility + +1. Current JSON and authored source become schema/source5. Preserve real schema4/source4 fixtures before updating any tests intended to describe current syntax. Existing V1/V2 goldens and real V3 fixtures remain unchanged. +2. Add frozen `Trace4`/`TraceBaseline4` with denied unknown fields and exact old prose values. Reuse unchanged typed elements, relations, diagrams and artifacts in an explicit Project4 envelope with duplicate-map rejection. Do not copy the full metamodel. A schema4 input containing a semantic baseline must reject instead of silently accepting a future payload. +3. Adapt frozen schema3 trace decoding to use the same old trace shape; schema2 already has a frozen model. Ensure V1/V2/V3 paths enter the explicit review upgrade before returning current5, preserving their existing data/port migration report details. The original reported `from_schema` remains unchanged and `to_schema` is 5. +4. A migrated trace keeps IDs, aliases, rationale, endpoints and exact prose baselines; it receives semantic=None. Do not construct reviewed semantic observations from today's model. No file write, hosted revision advance or history entry occurs during decode. +5. Source3/source4 bundle compilation passes through `resolve_declarations`, bypassing `parse_document`; apply the same strict legacy trace decoding there. Upgrade authored4 with a version-token edit only, plus UUID stabilization when actually missing. Authored3 retains its existing targeted data edits and finishes at source5. Preserve includes, lexical ownership, comments, line endings and file layout; no canonical-bundle fallback. + +## Typed historical source context + +Add an explicit `uuid "..."` AST atom. It is permitted only in reference slots of a typed semantic facet under a trace baseline. Ordinary live references continue to require `@alias`; trace endpoints, parents, ports, payload paths and other live fields do not gain a quoted-UUID escape hatch. Historical references never require a current alias or live object. + +Coordinate a closed source-shape/reference-slot catalog with the core facet types. `oa`, `sa` and `Value` wrappers cannot use the existing leaf-name reference heuristic: the same spelling can wrap a bound, enum, string, optional UUID, keyed row or order vector. Reject a historical UUID atom in text/numeric/enum slots and reject `@alias` or a plain quoted UUID in historical reference slots. Typed serde admission remains responsible for exact payload shape; the source adapter preserves the token category distinction. No public generic SemanticPath/SemanticValue tree or escaped JSON blob is introduced. + +Canonical output prints historical UUID atoms even when the object currently exists, making the historical meaning stable across deletion. Live aliases remain canonical for live references. Source analysis/indexing treats both-token UUID atoms as one value span. The targeted updater compares keyed facet rows by their closed selector identity, preserves comments on retained rows, and updates all historical UUID tokens when core reidentifies an identity. A UUID-looking ordinary text annotation must stay ordinary text. + +## Verification required for handoff + +- Strict schema4 JSON and source4 migration preserves existing trace prose, IDs, aliases, ports and unrelated records, with semantic=None and no fabricated acknowledgement. Unknown schema5 semantic data under a version4 header rejects. Duplicate maps/fields remain rejected. +- Current5 canonical source and JSON roundtrip a representative typed review baseline with optional/unbounded values, NotApplicable constructor branches, ordered membership, complete `(path, field)` annotation keys, bindings and deleted historical UUIDs. Historical UUID syntax is rejected in live fields, and live aliases/plain strings are rejected in historical ID slots. +- Apply and format a real reviewed change, retain an intentionally different target value, delete a previously referenced object, then roundtrip and reidentify without losing history. Confirm core deletion does not prune historical values and real reidentification changes the exact corresponding tokens. +- A multi-file authored4 bundle with nested declarations and commented trace fields upgrades via targeted edits; a later facet-row update/reidentify preserves includes, comments and unrelated bytes. Exercise disk save/reload through existing source-file behavior where it adds evidence beyond the pure bundle test. +- Exercise the actual `compile_bundle` path as well as single-document parse. Preserve original V1/V2/V3 compatibility coverage and current source writer/identity/nesting regressions. Report test names and actual results only after the coordinated Cargo turn. + +## Recorded evidence + +The completed regression run passed 127 tests: 20 library tests, 16 language, 8 port migration, 3 authored4 port source, 5 R3 migration, 7 R3 source, 7 R6 migration, 5 R6 source, 8 source files, 3 identity updates, 12 nested updates, and 33 source project tests. These cover explicit original1/2/3 and frozen4 upgrade, strict older-header rejection of semantic data, unchanged reviewed prose, authored4 include/nesting/comment preservation, typed semantic source/JSON roundtrips with absent historical IDs, token context admission, row comment ownership through reordering and reidentification, and deletion retaining historical review. `cargo check -p oa-core --lib` also passed with the new dispatch and source adapter. + +The R3 source tests also pass actual included-review disk load, targeted edit, save and reopen, plus a real prepared semantic review transaction that takes the source name while retaining the intentionally different target unit and original prose baseline. The source regression suite's prior future-header rejection was updated from 5 to 6, and current-syntax test templates now use 5. The real source4 typed-port tests intentionally remain authored4 and assert their explicit migration report; frozen V1/V2/V3 inputs remain unchanged. Strict Clippy passed for the library and all eleven selected integration suites with `-D warnings`, using the shared vendored config and dedicated target. Later planner changes remain the core worker's responsibility to recheck against `r3_source`.