fix(source): admit ordinary schema eight with current roundtrip coverage

This commit is contained in:
2026-09-24 20:31:39 -07:00
parent edf096cd33
commit 33b86a4d6b
4 changed files with 401 additions and 6 deletions
+8 -4
View File
@@ -3,7 +3,7 @@ mod raw_tokens;
#[path = "language/semantic_source.rs"]
mod semantic_source;
mod source3_bridge;
// Staged syntax foundation only. Public source8 admission waits for complete
// Staged typed analysis only. Public mixed source8 admission waits for complete
// declaration lowering, source updates and the root-owned resource gate.
#[cfg(test)]
mod analysis_source;
@@ -338,7 +338,7 @@ fn enum_field(key: &str) -> bool {
)
}
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
/// Parse ordinary V8, or explicitly migrate a supported older source candidate. Parsing never
/// adopts a model and never overwrites an editor draft.
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
parse_limited(source, ParseLimits::UNLIMITED)
@@ -1548,12 +1548,12 @@ impl Parser<'_> {
if if mixed_mode {
self.text() != "8"
} else {
!matches!(self.text(), "3" | "4" | "5" | "6" | "7")
!matches!(self.text(), "3" | "4" | "5" | "6" | "7" | "8")
} {
return Err(self.error(if mixed_mode {
"Private mixed syntax requires arcadia 8."
} else {
"Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7."
"Unsupported schema version: expected arcadia 3, 4, 5, 6, 7 or 8."
}));
}
let schema_version = self.text().parse().expect("checked source version");
@@ -2283,6 +2283,10 @@ pub fn analyze(source: &str) -> Analysis {
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
#[cfg(test)]
#[path = "language_ordinary_source8_tests.rs"]
mod ordinary_source8;
pub(crate) mod legacy {
//! Arcadia text: a readable, lossless representation of a valid OA project.
//!
@@ -0,0 +1,335 @@
//! Ordinary current-schema compatibility; typed analysis authoring remains private.
use super::*;
use crate::source_project::{self, ProjectLimits, SourceBundle, compile_bundle};
fn example_bundle() -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia",
include_str!("../../../examples/source-project/project.arcadia"),
),
(
"oa.arcadia",
include_str!("../../../examples/source-project/oa.arcadia"),
),
(
"sa/participants.arcadia",
include_str!("../../../examples/source-project/sa/participants.arcadia"),
),
(
"sa/reporting.arcadia",
include_str!("../../../examples/source-project/sa/reporting.arcadia"),
),
(
"sa/assurance.arcadia",
include_str!("../../../examples/source-project/sa/assurance.arcadia"),
),
(
"views.arcadia",
include_str!("../../../examples/source-project/views.arcadia"),
),
]
.into_iter()
.map(|(path, text)| (path.into(), text.into()))
.collect(),
}
}
fn document(version: &str, body: &str) -> String {
format!(
"arcadia {version};\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}\n"
)
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
#[test]
fn current_formatter_roundtrips_complete_ordinary_examples_and_history() {
let input = example_bundle();
let before = input.clone();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
let example = compiled.decoded.project;
assert!(example.elements.values().any(|e| e.layer() == Layer::Oa));
assert!(example.elements.values().any(|e| e.layer() == Layer::Sa));
assert!(!example.diagrams.is_empty());
assert!(!example.traces.is_empty());
let historical =
crate::persistence::decode(include_bytes!("../tests/fixtures/v4-reviewed-prose.json"))
.unwrap();
assert!(historical.traces.values().any(|t| t.baseline.is_some()));
for project in [example, historical] {
assert!(project.definitions.is_empty());
let source = format(&project);
assert!(source.starts_with("arcadia 8;"));
let decoded = parse_with_report(&source).unwrap();
assert_eq!(decoded.project, project);
assert!(decoded.migration.is_none());
let bundle = source_project::format_bundle(&project);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle, bundle);
assert_eq!(compiled.decoded.project, project);
assert!(compiled.decoded.migration.is_none());
let again = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(again.bundle, compiled.bundle);
assert_eq!(again.index, compiled.index);
assert_eq!(again.decoded.project, project);
assert_eq!(again.diagnostics, compiled.diagnostics);
assert!(again.decoded.migration.is_none());
}
assert_eq!(input, before);
}
#[test]
fn versions_three_through_seven_migrate_to_idempotent_ordinary_eight() {
for version in ["3", "4", "5", "6", "7"] {
let source = document(version, "");
let decoded = parse_with_report(&source).unwrap();
assert_eq!(decoded.project.schema_version, 8);
assert!(decoded.migration.is_some());
let input = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
};
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, decoded.project);
assert!(compiled.decoded.migration.is_some());
assert!(compiled.bundle.files["project.arcadia"].starts_with("arcadia 8;"));
let again = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert!(again.decoded.migration.is_none());
assert_eq!(again.decoded.project, decoded.project);
assert_eq!(again.bundle, compiled.bundle);
assert_eq!(again.index, compiled.index);
assert_eq!(input.files["project.arcadia"], source);
}
}
#[test]
fn included_nested_omitted_ids_keep_actual_index_and_unicode_crlf() {
let entry =
"// 海岸\r\narcadia 8;\r\nproject coast \"Coast\" { include \"team.arcadia\"; }\r\n";
let fragment = "// Keep nested ownership é\r\noa_entity team \"Team\" {\r\n oa_actor sailor \"Sailor\" { description \"海\"; }\r\n}\r\n";
let input = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry.into()),
("team.arcadia".into(), fragment.into()),
]),
};
let before = input.clone();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert!(compiled.decoded.migration.is_none());
let p = &compiled.decoded.project;
let team = identity(p, "team");
let sailor = identity(p, "sailor");
assert_eq!(p.elements[&sailor].parent, Some(team));
assert_eq!(p.elements[&sailor].description, "海");
assert_eq!(compiled.index.lexical_owners[&sailor], team);
for (id, prefix) in [
(team, "oa_entity team \"Team\""),
(sailor, "oa_actor sailor \"Sailor\""),
] {
let location = &compiled.index.declarations[&id];
assert_eq!(location.path, "team.arcadia");
let actual = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
assert!(actual.starts_with(prefix));
assert!(actual.contains(&format!("id \"{id}\"")));
assert!(actual.ends_with('}'));
}
assert!(compiled.bundle.files["project.arcadia"].starts_with("// 海岸\r\narcadia 8;\r\n"));
assert!(compiled.bundle.files["team.arcadia"].starts_with("// Keep nested ownership é\r\n"));
for text in compiled.bundle.files.values() {
assert_eq!(text.matches('\n').count(), text.matches("\r\n").count());
}
assert!(compiled.diagnostics.is_empty());
let again = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(again.bundle, compiled.bundle);
assert_eq!(again.index, compiled.index);
assert_eq!(again.decoded.project, *p);
let mut stale = again.clone();
stale.decoded.project.name = "stale".into();
let error = source_project::update_bundle(&stale, p, ProjectLimits::default()).unwrap_err();
assert_eq!(error[0].diagnostic.code, "stale_source_index");
assert_eq!(input, before);
}
#[test]
fn typed_analysis_future_headers_and_limits_still_refuse() {
for word in [
"quantity_kind",
"unit",
"calculation",
"oa_property",
"sa_property",
"oa_check",
"sa_check",
"oa_case",
"sa_case",
"oa_engineering_property",
"sa_engineering_property",
"oa_analysis_check",
"sa_analysis_check",
"oa_analysis_case",
"sa_analysis_case",
] {
let fragment = format!("{word} x \"X\" id \"00000000-0000-0000-0000-000000000002\" {{}}\n");
let source = document("8", &fragment);
assert!(parse(&source).is_err(), "{word}");
let input = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
document("8", "include \"part.arcadia\";"),
),
("part.arcadia".into(), fragment.clone()),
]),
};
let error = compile_bundle(&input, ProjectLimits::default()).unwrap_err();
assert_eq!(error[0].path, "part.arcadia", "{word}");
assert_eq!(error[0].diagnostic.code, "invalid_source");
assert_eq!(error[0].diagnostic.span.start, 0);
if word.contains("engineering_property")
|| word.contains("analysis_check")
|| word.contains("analysis_case")
{
assert_eq!(
error[0].diagnostic.message,
"Analysis records require the source8 authoring implementation."
);
}
assert_eq!(input.files["part.arcadia"], fragment);
}
for version in ["0", "9", "99", "8.0"] {
let error = parse(&document(version, "")).unwrap_err();
assert_eq!(
error[0].message,
"Unsupported schema version: expected arcadia 3, 4, 5, 6, 7 or 8."
);
assert_eq!(error[0].span.start, 8);
}
let source = document("8", "");
let error = parse_limited(
&source,
ParseLimits {
max_source_bytes: source.len() - 1,
..ParseLimits::UNLIMITED
},
)
.unwrap_err();
assert_eq!(error[0].code, "source_limit");
let error = parse_limited(
&source,
ParseLimits {
max_records: 0,
..ParseLimits::UNLIMITED
},
)
.unwrap_err();
assert_eq!(error[0].code, "model_limit");
assert!(parse("arcadia 8; project").is_err());
}
#[test]
fn public_checked_save_reopens_ordinary_source_and_json_but_refuses_lost_definition() {
let project = parse_with_report(include_str!("../../../examples/text-first/project.arcadia"))
.unwrap()
.project;
let dir = tempfile::tempdir().unwrap();
for name in ["ordinary.arcadia", "ordinary.json"] {
let path = dir.path().join(name);
let bytes = crate::persistence::save_checked(&path, &project, None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let decoded = crate::persistence::decode_with_report(&bytes).unwrap();
assert_eq!(decoded.project, project);
assert!(decoded.migration.is_none());
assert_eq!(
crate::persistence::save_checked(&path, &project, Some(&bytes)).unwrap(),
bytes
);
}
let mut with_definition = Project::empty();
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
with_definition.definitions.insert(
id,
crate::analysis::Definition {
id,
alias: "duration".into(),
name: "Duration".into(),
description: String::new(),
body: crate::analysis::DefinitionBody::QuantityKind(
crate::analysis::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::new([0, 0, 1, 0, 0, 0, 0])
.unwrap(),
algebra: crate::analysis::QuantityAlgebra::Linear,
},
),
},
);
let path = dir.path().join("protected.arcadia");
let sentinel = b"external unfinished draft\r\n";
std::fs::write(&path, sentinel).unwrap();
let count = std::fs::read_dir(dir.path()).unwrap().count();
let error =
crate::persistence::save_checked(&path, &with_definition, Some(sentinel)).unwrap_err();
assert!(
matches!(error,crate::persistence::FileError::Invalid(message) if message=="Generated source does not reproduce the complete requested project.")
);
assert_eq!(std::fs::read(&path).unwrap(), sentinel);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), count);
}
#[test]
fn cli_preview_literals_compile_to_independent_complete_projects() {
let entry = "// 雨\r\narcadia 8;\r\nproject p \"Project\" id \"00000000-0000-0000-0000-000000000001\" {\r\ninclude \"participants.arcadia\"; include \"children.arcadia\";\r\n}\r\n";
let before_team = "// @team remains documentation\r\noa_entity team \"Team @team\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n";
let after_team = "// @team remains documentation\r\noa_entity rescue_team \"Team @team\" id \"00000000-0000-0000-0000-000000000002\" {}\r\n";
let before_child = "oa_entity child \"Child\" id \"00000000-0000-0000-0000-000000000003\" { parent @team; }\r\n";
let after_child = "oa_entity child \"Child\" id \"00000000-0000-0000-0000-000000000003\" { parent @rescue_team; }\r\n";
let project_id = Id::from_u128(1);
let team_id = Id::from_u128(2);
let child_id = Id::from_u128(3);
let mut expected = Project::empty();
expected.id = project_id;
expected.alias = "p".into();
expected.name = "Project".into();
for (id, alias, name, parent) in [
(team_id, "team", "Team @team", None),
(child_id, "child", "Child", Some(team_id)),
] {
expected.elements.insert(
id,
Element {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body: ElementBody::new(Kind::Entity, Layer::Oa).unwrap(),
},
);
}
for (team, child, alias) in [
(before_team, before_child, "team"),
(after_team, after_child, "rescue_team"),
] {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
("project.arcadia", entry),
("participants.arcadia", team),
("children.arcadia", child),
]
.into_iter()
.map(|(path, source)| (path.into(), source.into()))
.collect(),
};
expected.elements.get_mut(&team_id).unwrap().alias = alias.into();
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, expected);
assert!(compiled.decoded.migration.is_none());
assert!(compiled.diagnostics.is_empty());
assert_eq!(compiled.bundle, bundle);
}
}