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.
|
||||
#[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 {
|
||||
|
||||
@@ -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