Construct the frozen source3 migration envelope explicitly

This commit is contained in:
2026-09-12 21:38:14 -07:00
parent 9ff6e7cf7a
commit 6216cff4cf
3 changed files with 242 additions and 4 deletions
+2 -4
View File
@@ -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")
);
}