diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 1342c092..1b48474e 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -1,6 +1,7 @@ //! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration. #[path = "language/semantic_source.rs"] mod semantic_source; +mod source3_bridge; // Staged syntax foundation only. Public source8 admission waits for complete // declaration lowering, source updates and the root-owned resource gate. #[cfg(test)] @@ -1834,10 +1835,7 @@ pub(crate) fn resolve_declarations( target.nodes.insert(node.id, node); } if legacy { - let mut value = serde_json::to_value(&project).expect("project serialization"); - value["elements"] = Value::Object(legacy_elements); - let migrated = serde_json::from_value(value) - .map_err(crate::persistence::FileError::Json) + let migrated = source3_bridge::from_scratch(project, legacy_elements) .and_then(|old| crate::persistence::v3::migrate(old, limits)) .map_err(|error| { let issue = if let crate::persistence::FileError::Model { issues, .. } = &error { diff --git a/crates/oa-core/src/language/source3_bridge.rs b/crates/oa-core/src/language/source3_bridge.rs new file mode 100644 index 00000000..65127f42 --- /dev/null +++ b/crates/oa-core/src/language/source3_bridge.rs @@ -0,0 +1,52 @@ +//! Convert only source3 lowering scratch state into its frozen migration input. +//! This is not a current-to-legacy exporter: fields outside that scratch state +//! must reject instead of being silently discarded. +use crate::Project; +use crate::persistence::{FileError, v3}; +use serde_json::{Map, Value}; + +pub(super) fn from_scratch( + project: Project, + legacy_elements: Map, +) -> Result { + // No `..`: a future Project field requires an explicit compatibility decision. + let Project { + schema_version, + id, + alias, + name, + description, + definitions, + elements, + relations, + diagrams, + artifact_links, + traces, + } = project; + if schema_version != 3 { + return Err(FileError::Invalid( + "Expected source3 lowering state.".into(), + )); + } + if !definitions.is_empty() || !elements.is_empty() { + return Err(FileError::Invalid( + "Source3 lowering state cannot contain current definitions or elements.".into(), + )); + } + Ok(v3::Project3 { + schema_version, + id, + alias, + name, + description, + elements: serde_json::from_value(Value::Object(legacy_elements))?, + // Keep the frozen nested contracts, including strict trace baselines. + relations: serde_json::from_value(serde_json::to_value(relations)?)?, + diagrams, + artifact_links, + traces: serde_json::from_value(serde_json::to_value(traces)?)?, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/source3_bridge/tests.rs b/crates/oa-core/src/language/source3_bridge/tests.rs new file mode 100644 index 00000000..cb2c6542 --- /dev/null +++ b/crates/oa-core/src/language/source3_bridge/tests.rs @@ -0,0 +1,188 @@ +use super::*; +use crate::{Id, language, persistence}; +use serde_json::json; + +const SOURCE3: &str = include_str!("../../../tests/fixtures/v3-data-contract.arcadia"); +const JSON3: &[u8] = include_bytes!("../../../tests/fixtures/v3-data-contract.json"); +const PROSE: &str = r#"arcadia 3; +project review "Review" id "00000000-0000-0000-0000-000000000001" { + oa_activity reviewed_activity "Live OA" id "00000000-0000-0000-0000-000000000002" {} + sa_function function "Live SA" id "00000000-0000-0000-0000-000000000003" {} + trace review_link "Review link" id "00000000-0000-0000-0000-000000000004" { + oa @reviewed_activity; sa @function; kind Refinement; + baseline { + oa { name "Approved OA"; description "First\nΩ rationale"; }; + sa { name "Approved SA"; description "Prior SA prose"; }; + }; + } +}"#; + +fn id(value: u128) -> Id { + Id::from_u128(value) +} + +fn prose_json() -> Value { + json!({ + "schema_version":3,"id":id(1),"alias":"review","name":"Review","description":"", + "elements": { + id(2).to_string(): {"id":id(2),"alias":"reviewed_activity","name":"Live OA","description":"","parent":null,"body":{"Oa":"Activity"}}, + id(3).to_string(): {"id":id(3),"alias":"function","name":"Live SA","description":"","parent":null,"body":{"Sa":"Function"}} + }, + "relations":{},"diagrams":{},"artifact_links":{}, + "traces": {id(4).to_string():{ + "id":id(4),"alias":"review_link","name":"Review link","description":"", + "oa":id(2),"sa":id(3),"kind":"Refinement", + "baseline":{"oa":{"name":"Approved OA","description":"First\nΩ rationale"},"sa":{"name":"Approved SA","description":"Prior SA prose"}} + }} + }) +} + +#[test] +fn frozen_source3_and_json3_have_complete_model_and_migration_parity() { + let source = language::parse_with_report(SOURCE3).unwrap(); + let json = persistence::decode_with_report(JSON3).unwrap(); + assert_eq!(source.project, json.project); + assert_eq!(source.migration, json.migration); + assert!(source.project.definitions.is_empty()); + let report = source.migration.unwrap(); + assert_eq!( + (report.from_schema, report.to_schema), + (3, crate::SCHEMA_VERSION) + ); + // This tests direct legacy lowering. Canonical source8 reopening remains a + // separate, unchanged public integration gate. +} + +#[test] +fn frozen_wire_still_rejects_new_unknown_and_duplicate_fields() { + let original: Value = serde_json::from_slice(JSON3).unwrap(); + for definitions in [ + json!({}), + Value::Null, + json!({id(90).to_string(): {"name":"new"}}), + ] { + let mut bad = original.clone(); + bad["definitions"] = definitions; + let error = persistence::decode(&serde_json::to_vec(&bad).unwrap()) + .err() + .unwrap() + .to_string(); + assert!(error.contains("unknown field `definitions`"), "{error}"); + } + let mut bad = original; + bad["unrecognized"] = json!(true); + assert!( + persistence::decode(&serde_json::to_vec(&bad).unwrap()) + .err() + .unwrap() + .to_string() + .contains("unknown field `unrecognized`") + ); + let raw = std::str::from_utf8(JSON3).unwrap(); + let duplicate = raw.replacen( + "\"schema_version\": 3", + "\"schema_version\": 3, \"schema_version\": 3", + 1, + ); + assert_ne!(duplicate, raw); + assert!( + persistence::decode(duplicate.as_bytes()) + .err() + .unwrap() + .to_string() + .contains("duplicate") + ); +} + +#[test] +fn old_review_prose_survives_without_admitting_semantic_fields() { + let source = language::parse_with_report(PROSE).unwrap(); + let json = + persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()).unwrap(); + assert_eq!(source.project, json.project); + assert_eq!(source.migration, json.migration); + let baseline = source.project.traces[&id(4)].baseline.as_ref().unwrap(); + assert_eq!(baseline.oa.name, "Approved OA"); + assert_eq!(baseline.oa.description, "First\nΩ rationale"); + assert_eq!(baseline.sa.description, "Prior SA prose"); + assert!(baseline.semantic.is_none()); + for semantic in [Value::Null, json!({"facets":[]})] { + let mut bad = prose_json(); + bad["traces"][id(4).to_string()]["baseline"]["semantic"] = semantic; + let error = persistence::decode(&serde_json::to_vec(&bad).unwrap()) + .err() + .unwrap() + .to_string(); + assert!(error.contains("semantic"), "{error}"); + } + for field in ["semantic none;", "semantic { facets []; };"] { + let bad = PROSE.replace("baseline {", &format!("baseline {{ {field}")); + let errors = language::parse(&bad).err().unwrap(); + assert!( + errors + .iter() + .any(|e| e.message.contains("require schema/source5")), + "{errors:?}" + ); + } +} + +#[test] +fn scratch_bridge_refuses_to_discard_current_only_data() { + let mut empty = Project::empty(); + empty.schema_version = 3; + assert!(from_scratch(empty.clone(), Map::new()).is_ok()); + let mut wrong_version = empty.clone(); + wrong_version.schema_version = crate::SCHEMA_VERSION; + assert!(from_scratch(wrong_version, Map::new()).is_err()); + let mut definition = empty; + definition.definitions.insert( + id(90), + crate::analysis::Definition { + id: id(90), + alias: "dimensionless".into(), + name: "Dimensionless".into(), + description: String::new(), + body: crate::analysis::DefinitionBody::QuantityKind( + crate::analysis::QuantityKindDefinition { + dimension: crate::analysis_numeric::Dimension::ZERO, + algebra: crate::analysis::QuantityAlgebra::Linear, + }, + ), + }, + ); + assert!( + from_scratch(definition, Map::new()) + .err() + .unwrap() + .to_string() + .contains("definitions or elements") + ); + let mut current = persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()) + .unwrap() + .project; + current.schema_version = 3; + assert!( + from_scratch(current.clone(), Map::new()) + .err() + .unwrap() + .to_string() + .contains("definitions or elements") + ); + current.elements.clear(); + current + .traces + .get_mut(&id(4)) + .unwrap() + .baseline + .as_mut() + .unwrap() + .semantic = Some(crate::semantic::SemanticBaseline { facets: Vec::new() }); + assert!( + from_scratch(current, Map::new()) + .err() + .unwrap() + .to_string() + .contains("semantic") + ); +}