From 33b86a4d6b4bc1b156a93ae3596fe0ad7b4dbbe5 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 20:31:39 -0700 Subject: [PATCH] fix(source): admit ordinary schema eight with current roundtrip coverage --- crates/oa-core/src/language.rs | 12 +- .../src/language_ordinary_source8_tests.rs | 335 ++++++++++++++++++ crates/oa-core/tests/source7_contract.rs | 7 +- ...8-compatibility-implementation-20260925.md | 53 +++ 4 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 crates/oa-core/src/language_ordinary_source8_tests.rs create mode 100644 harness/ordinary-source8-compatibility-implementation-20260925.md diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 920de343..53ab722e 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -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> { 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. //! diff --git a/crates/oa-core/src/language_ordinary_source8_tests.rs b/crates/oa-core/src/language_ordinary_source8_tests.rs new file mode 100644 index 00000000..83144657 --- /dev/null +++ b/crates/oa-core/src/language_ordinary_source8_tests.rs @@ -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); + } +} diff --git a/crates/oa-core/tests/source7_contract.rs b/crates/oa-core/tests/source7_contract.rs index 62e5e647..9c587d40 100644 --- a/crates/oa-core/tests/source7_contract.rs +++ b/crates/oa-core/tests/source7_contract.rs @@ -1036,16 +1036,19 @@ fn source7_source_bundle_migration_is_token_only_and_new_link_stays_with_caller( let f = chain(Layer::Oa); let mut bundle = source_project::format_bundle(&f.p); let entry = bundle.files.get_mut(&bundle.entry).unwrap(); + assert!(entry.starts_with("arcadia 8;")); *entry = entry - .replacen("arcadia 7;", "arcadia // preserve this header\r\n 6;", 1) + .replacen("arcadia 8;", "arcadia // preserve this header\r\n 6;", 1) .replace('\n', "\r\n"); + assert!(entry.starts_with("arcadia // preserve this header")); + assert_eq!(entry.matches(" 6;").count(), 1); let old = bundle.clone(); let compiled = source_project::compile_bundle(&bundle, Default::default()).unwrap(); assert_eq!(compiled.decoded.project, f.p); assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 6); for (path, source) in &old.files { let expected = if path == &old.entry { - source.replacen(" 6;", " 7;", 1) + source.replacen(" 6;", " 8;", 1) } else { source.clone() }; diff --git a/harness/ordinary-source8-compatibility-implementation-20260925.md b/harness/ordinary-source8-compatibility-implementation-20260925.md new file mode 100644 index 00000000..b0004c28 --- /dev/null +++ b/harness/ordinary-source8-compatibility-implementation-20260925.md @@ -0,0 +1,53 @@ +# Ordinary source8 compatibility implementation + +Base `edf096cd33350865ecfc3c65ec266a6c0d3c4000`, selected parent contract +`harness/ordinary-source8-compatibility-contract-20260925.md` at root fb9b0c5c. +The public writer already emits8. The ordinary Parser::unit version list now +accepts8 and its unsupported-version diagnostic names the accurate range. +Current ordinary resolution already checks model limits and returns no migration; +3–7 retain their existing migration paths. No new converter or dispatch adapter +was introduced. Comments distinguish this ordinary repair from private mixed +analysis authoring. + +Production change is intentionally confined to that version boundary. Long +analysis constructors retain their explicit conversion refusal, and short typed +declarations remain outside the ordinary grammar. Mixed cfg gates, formatter, +public SourceIndex/update semantics, ID stabilization, resource/depth policies +and codec switches are unchanged. Ordinary8 is not complete analysis/source8. + +Six new tests use the ordinary_source8 module filter. They reuse the substantial +six-file source-project example and historical reviewed-prose fixture for complete +model/current formatter and bundle/index idempotence. Other groups cover3–7 +migration to stable8; nested included declarations with omitted IDs, UTF-8/CRLF, +comments and actual returned index/identity spans; stale compiled input; short +and long analysis rejection, future/malformed headers and caller limits. Public +checked-save exercises both source and JSON, actual returned bytes/reopen/repeat +save, and rejection of the real formatter's omitted definition catalog while +preserving existing destination bytes. These are authored evaluators, not passing +execution claims. The three-file CLI preview literals additionally compile to an independently constructed complete Project before and after only the team alias changes; actual CLI execution remains a separate parent gate. + +The prior test exception below repairs fixture construction without weakening +its semantics; all other prior test bodies/expectations remain unchanged. The16 formerly header-masked +library workflows must run their intended nested/update/migration/transaction +assertions; new downstream failures must be investigated on their own merits. +Parent owns focused/full/integration/CLI/non-test consumer/Clippy/format gates, +including previously unreached ordinary JSON branches. DEPTH_128_VALIDATED stays +false, and ordinary8 does not qualify the private depth candidate, user-deferred +Linux or the separately recorded full-pipeline setup limitation. Source/public +save equality and no-staging-before-validation safeguards remain mandatory. + +Only changed Rust files were directly formatted with skip_children; source review +found no production blocker. Compiler/runtime acceptance remains parent-owned. + +## Authorized pre-execution evaluator repair + +Independent integration inventory found source7_contract's +source7_source_bundle_migration_is_token_only_and_new_link_stays_with_caller +attempted to replace formatted header7 with commented6, although the formatter +already emits8. That replacement was a no-op; the later expected7 was stale too. +Parent authorized this one existing evaluator correction: first assert actual +header8, replace8 with the original commented6 fixture, assert the comment and +single6 token, and expect only6->8 during migration. All exact file/comment/module, +whole-model, caller ownership and recompilation assertions remain, as does the +original test name. This is a static finding before execution, not a reported +runtime failure; no other legacy evaluator or formatter is changed.