Construct the frozen source3 migration envelope explicitly
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
||||||
#[path = "language/semantic_source.rs"]
|
#[path = "language/semantic_source.rs"]
|
||||||
mod semantic_source;
|
mod semantic_source;
|
||||||
|
mod source3_bridge;
|
||||||
// Staged syntax foundation only. Public source8 admission waits for complete
|
// Staged syntax foundation only. Public source8 admission waits for complete
|
||||||
// declaration lowering, source updates and the root-owned resource gate.
|
// declaration lowering, source updates and the root-owned resource gate.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1834,10 +1835,7 @@ pub(crate) fn resolve_declarations(
|
|||||||
target.nodes.insert(node.id, node);
|
target.nodes.insert(node.id, node);
|
||||||
}
|
}
|
||||||
if legacy {
|
if legacy {
|
||||||
let mut value = serde_json::to_value(&project).expect("project serialization");
|
let migrated = source3_bridge::from_scratch(project, legacy_elements)
|
||||||
value["elements"] = Value::Object(legacy_elements);
|
|
||||||
let migrated = serde_json::from_value(value)
|
|
||||||
.map_err(crate::persistence::FileError::Json)
|
|
||||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
||||||
|
|||||||
@@ -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<String, Value>,
|
||||||
|
) -> Result<v3::Project3, FileError> {
|
||||||
|
// 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;
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user