Integrate semantic review core source API and scalar desktop workflow

This commit is contained in:
2026-09-06 06:10:14 +03:00
68 changed files with 17761 additions and 190 deletions
@@ -507,6 +507,7 @@ fn workshop() -> Project {
let baseline = TraceBaseline {
oa: p.properties(t.oa).unwrap(),
sa: p.properties(t.sa).unwrap(),
semantic: None,
};
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
}
+1
View File
@@ -111,6 +111,7 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
+67 -10
View File
@@ -1,4 +1,6 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -387,7 +389,7 @@ fn enum_field(key: &str) -> bool {
)
}
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 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)
@@ -552,7 +554,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
/// nested records are ordinary language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -743,6 +745,10 @@ fn write_value(
indent: usize,
aliases: &BTreeMap<Id, String>,
) {
if key == "semantic" {
semantic_source::write(out, value, indent);
return;
}
match value {
Value::Null => out.push_str("none"),
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
@@ -786,6 +792,7 @@ enum Ast {
Literal(Value, usize),
Word(String, usize),
Reference(String, usize),
HistoricalUuid(Id, usize),
Array(Vec<Ast>, usize),
Object(BTreeMap<String, Ast>, usize),
}
@@ -795,12 +802,20 @@ impl Ast {
Self::Literal(_, p)
| Self::Word(_, p)
| Self::Reference(_, p)
| Self::HistoricalUuid(_, p)
| Self::Array(_, p)
| Self::Object(_, p) => *p,
}
}
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
match self {
Self::HistoricalUuid(_, _) => Err(Error {
code: "invalid_source",
offset: self.offset(),
message:
"A uuid literal is allowed only in a typed historical semantic reference slot."
.into(),
}),
Self::Word(word, offset) => {
if !enum_field(key) {
return Err(Error {
@@ -968,6 +983,7 @@ pub(crate) struct EditValue {
pub(crate) enum EditKind {
Atom,
Reference(String),
HistoricalUuid(Id),
Record(BTreeMap<String, EditField>),
List(Vec<EditValue>),
}
@@ -1080,7 +1096,7 @@ fn edit_positions(
let span = Span {
start: ast.offset(),
end: match ast {
Ast::Reference(..) => self.tokens[index + 1].span.end,
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
_ => self.ends[index],
},
};
@@ -1090,6 +1106,7 @@ fn edit_positions(
EditKind::List(items.iter().map(|item| self.value(item)).collect())
}
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
_ => EditKind::Atom,
};
EditValue { span, kind }
@@ -1270,6 +1287,16 @@ impl Parser<'_> {
let token = self.current();
let offset = token.span.start;
match self.text() {
"uuid" => {
self.cursor += 1;
let value = self.quoted()?;
let id = Id::parse_str(&value).map_err(|_| Error {
code: "invalid_source",
offset,
message: "Expected a UUID after uuid.".into(),
})?;
Ok(Ast::HistoricalUuid(id, offset))
}
"@" => {
self.cursor += 1;
Ok(Ast::Reference(self.identifier()?, offset))
@@ -1356,11 +1383,11 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4") {
if !matches!(self.text(), "3" | "4" | "5") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
}
let schema_version = if self.text() == "3" { 3 } else { 4 };
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
self.take(";")?;
let start = self.current().span.start;
@@ -1557,7 +1584,12 @@ pub(crate) fn resolve_declarations(
let mut fields = declaration
.fields
.iter()
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
.map(|(k, v)| {
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
} else { v.resolve(k, aliases)? };
Ok((k.clone(), value))
})
.collect::<Result<Map<_, _>, Error>>()?;
let d = declaration;
let conversion_error = |message: String| Error {
@@ -1729,8 +1761,12 @@ pub(crate) fn resolve_declarations(
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
let trace = if project.schema_version < 5 {
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
Trace::from(old)
} else {
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
};
project.traces.insert(d.id, trace);
}
}
@@ -1801,6 +1837,22 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if project.schema_version == 4 {
return crate::persistence::v4::finish(project, limits).map_err(|error| {
(
usize::MAX,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset: 0,
message: error.to_string(),
},
)
});
}
limits.check_model(&project).map_err(|message| {
(
usize::MAX,
@@ -2595,3 +2647,8 @@ pub(crate) mod legacy {
}
}
}
/// Closed historical facet identity for the targeted source writer.
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
semantic_source::row_key(source, origins)
}
@@ -0,0 +1,203 @@
//! Typed historical source leaves; live alias resolution remains in the parent.
use super::*;
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
fn error(ast: &Ast, message: impl Into<String>) -> Error {
Error {
code: "invalid_source",
offset: ast.offset(),
message: message.into(),
}
}
fn raw(ast: &Ast) -> Result<Value, Error> {
Ok(match ast {
Ast::Literal(value, _) => value.clone(),
Ast::Word(value, _) => Value::String(value.clone()),
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
Ast::Reference(_, _) => {
return Err(error(
ast,
"Historical semantic references require uuid \"...\", not @alias.",
));
}
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
Ast::Object(fields, _) => Value::Object(
fields
.iter()
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
.collect::<Result<_, Error>>()?,
),
})
}
fn validate_tokens(
ast: &Ast,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) -> Result<(), Error> {
match ast {
Ast::Object(fields, _) => {
for (key, value) in fields {
path.push(key.clone());
validate_tokens(value, path, slots)?;
path.pop();
}
}
Ast::Array(values, _) => {
for (index, value) in values.iter().enumerate() {
path.push(index.to_string());
validate_tokens(value, path, slots)?;
path.pop();
}
}
_ => match (slots.get(path), ast) {
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
(Some(HistoricalSourceKind::Reference), _) => {
return Err(error(
ast,
"This historical reference requires uuid \"...\".",
));
}
(Some(HistoricalSourceKind::Enum), _) => {
return Err(error(
ast,
"This typed historical enum requires an unquoted enum word.",
));
}
(None, Ast::HistoricalUuid(..)) => {
return Err(error(
ast,
"A uuid literal is allowed only in a typed historical reference slot.",
));
}
(None, Ast::Word(..)) => {
return Err(error(
ast,
"This historical text value requires a quoted string.",
));
}
_ => {}
},
}
Ok(())
}
pub(super) fn resolve_baseline(
ast: &Ast,
aliases: &BTreeMap<String, Id>,
version: u32,
) -> Result<Value, Error> {
let Ast::Object(fields, _) = ast else {
return ast.resolve("baseline", aliases);
};
let mut resolved = Map::new();
for (key, value) in fields {
if key != "semantic" {
resolved.insert(key.clone(), value.resolve(key, aliases)?);
continue;
}
if version < 5 {
return Err(error(
value,
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
));
}
if matches!(value, Ast::Literal(Value::Null, _)) {
resolved.insert(key.clone(), Value::Null);
continue;
}
let json = raw(value)?;
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
let slots = historical_source_slots(&baseline).into_iter().collect();
validate_tokens(value, &mut vec![], &slots)?;
resolved.insert(key.clone(), json);
}
Ok(Value::Object(resolved))
}
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
if value.is_null() {
out.push_str("none");
return;
}
let baseline: SemanticBaseline =
serde_json::from_value(value.clone()).expect("typed semantic baseline");
let slots = historical_source_slots(&baseline).into_iter().collect();
write_at(out, value, indent, &mut vec![], &slots);
}
fn write_at(
out: &mut String,
value: &Value,
indent: usize,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) {
match value {
Value::Null => out.push_str("none"),
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => out.push_str(&value.to_string()),
Value::String(value) => match slots.get(path) {
Some(HistoricalSourceKind::Reference) => {
out.push_str("uuid ");
out.push_str(&quote(value));
}
Some(HistoricalSourceKind::Enum) => out.push_str(value),
None => out.push_str(&quote(value)),
},
Value::Array(values) => {
out.push('[');
for (index, value) in values.iter().enumerate() {
if index != 0 {
out.push_str(", ");
}
path.push(index.to_string());
write_at(out, value, indent, path, slots);
path.pop();
}
out.push(']');
}
Value::Object(fields) => {
out.push_str("{\n");
for (key, value) in fields {
out.push_str(&" ".repeat(indent + 1));
out.push_str(key);
out.push(' ');
path.push(key.clone());
write_at(out, value, indent + 1, path, slots);
path.pop();
out.push_str(";\n");
}
out.push_str(&" ".repeat(indent));
out.push('}');
}
}
}
/// Recognize only complete typed facet rows. Their closed selector, with real
/// identity provenance applied, owns comments when review rows reorder.
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
let mut parser = Parser {
source,
tokens: strict_tokens(source).ok()?,
cursor: 0,
value_depth: 0,
limits: ParseLimits::UNLIMITED,
};
let ast = parser.value().ok()?;
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
let mut baseline = SemanticBaseline {
facets: vec![facet],
};
crate::semantic::visit_semantic_references(&mut baseline, |id| {
if let Some(original) = origins.get(id) {
*id = *original;
}
});
serde_json::to_string(&baseline.facets[0].selector())
.ok()
.map(|key| format!("semantic:{key}"))
}
+3 -3
View File
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
fn project_source(body: &str) -> String {
format!(
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let analysis = analyze(source);
let ready = analysis
.declarations
+2
View File
@@ -7,6 +7,7 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
pub mod source_files;
pub mod source_project;
@@ -14,6 +15,7 @@ pub mod transitions;
pub mod validation;
pub use model::*;
pub use ports::*;
pub use semantic::*;
pub use session::*;
mod demo;
mod state_validation;
+3 -1
View File
@@ -13,7 +13,9 @@ generic port/optional FE endpoint contracts therefore remain explicit.
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
then performs the data-contract upgrade and validates schema 4. Serialization
then performs the data-contract upgrade into the strict schema 4 envelope. The
schema 4 review adapter preserves its prose-only trace baselines and returns
schema 5 with semantic review absent. Serialization
between compatibility DTOs copies unchanged fields; original input bytes are
never deserialized into the new `Project`. V1 normalization also constructs this
V2 DTO before the shared stages. Combined generation stays within one budget.
+5 -3
View File
@@ -24,7 +24,7 @@ pub(crate) struct Project3 {
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
@@ -486,8 +486,10 @@ pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult
));
}
}
value["schema_version"] = SCHEMA_VERSION.into();
let mut project: Project = serde_json::from_value(value)?;
value["schema_version"] = 4.into();
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
let mut project = normalized.into_current()?;
crate::persistence::v4::review_defaults(&project, &mut report);
let mut references = project
.elements
.values()
+117
View File
@@ -0,0 +1,117 @@
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TraceBaseline4 {
pub oa: TracedProperties,
pub sa: TracedProperties,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Trace4 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub oa: Id,
pub sa: Id,
pub kind: TraceKind,
pub baseline: Option<TraceBaseline4>,
}
impl From<Trace4> for Trace {
fn from(old: Trace4) -> Self {
Self {
id: old.id,
alias: old.alias,
name: old.name,
description: old.description,
oa: old.oa,
sa: old.sa,
kind: old.kind,
baseline: old.baseline.map(|baseline| TraceBaseline {
oa: baseline.oa,
sa: baseline.sa,
semantic: None,
}),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project4 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace4>,
}
impl Project4 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if self.schema_version != 4 {
return Err(FileError::Invalid("Expected schema_version 4.".into()));
}
Ok(Project {
schema_version: SCHEMA_VERSION,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self
.traces
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
}
}
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
report.semantic_defaults.extend(
project
.traces
.values()
.filter(|trace| trace.baseline.is_some())
.map(|trace| SemanticDefault {
object: trace.id,
field: "baseline.semantic".into(),
description: "Original prose review preserved; semantic facets remain unreviewed."
.into(),
}),
);
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
project.schema_version = SCHEMA_VERSION;
limits.check_model(&project).map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(4);
review_defaults(&project, &mut report);
Ok(DecodeResult {
project,
migration: Some(report),
})
}
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
finish(old.into_current()?, limits)
}
+5 -3
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 4;
pub const SCHEMA_VERSION: u32 = 5;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -1492,13 +1492,15 @@ pub struct TracedProperties {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TraceBaseline {
pub oa: TracedProperties,
pub sa: TracedProperties,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<crate::semantic::SemanticBaseline>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trace {
pub id: Id,
+5 -2
View File
@@ -7,6 +7,8 @@ pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[path = "migration_v3/mod.rs"]
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -148,13 +150,14 @@ pub fn decode_with_report_limited(
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => DecodeResult {
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
));
}
}
+7
View File
@@ -222,6 +222,13 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
if t.sa == old {
t.sa = new;
}
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
crate::semantic::visit_semantic_references(baseline, |id| {
if *id == old {
*id = new;
}
});
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
+16
View File
@@ -0,0 +1,16 @@
//! Closed typed facts for deliberately reviewed OA/SA semantics.
//!
//! Historical facts are independently acknowledged observations, not a synthetic
//! live model. Their UUIDs are not live references and survive ordinary deletion.
mod catalog;
mod historical;
mod linked;
mod mutate;
mod review;
mod types;
pub use catalog::*;
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
pub use historical::{validate_semantic_baseline, visit_semantic_references};
pub use linked::*;
pub use review::*;
pub use types::*;
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
use super::types::*;
use crate::model::*;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HistoricalSourceKind {
Reference,
Enum,
}
const MAX_FACTS: usize = 16_384;
const MAX_LEAVES: usize = 1_000_000;
const MAX_PATH: usize = 128;
struct Walk<'a> {
path: Vec<String>,
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
errors: Vec<String>,
leaves: usize,
visitor: &'a mut dyn FnMut(&mut Id),
}
impl Walk<'_> {
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
if self.leaves >= MAX_LEAVES {
return;
}
self.leaves += 1;
self.path.push(key.to_string());
f(self);
self.path.pop();
}
fn slot(&mut self, kind: HistoricalSourceKind) {
self.slots.push((self.path.clone(), kind));
}
fn error(&mut self, message: &str) {
self.errors
.push(format!("{}: {message}", self.path.join(".")));
}
}
trait Historical {
fn walk(&mut self, w: &mut Walk<'_>);
}
impl Historical for Id {
fn walk(&mut self, w: &mut Walk<'_>) {
w.slot(HistoricalSourceKind::Reference);
(w.visitor)(self);
}
}
macro_rules! plain {
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
}
plain!(bool, u32, i64);
impl Historical for String {
fn walk(&mut self, w: &mut Walk<'_>) {
if !crate::validation::valid_text(self) {
w.error("historical text contains an invalid character");
}
if self.len() > 1_000_000 {
w.error("historical text work limit exceeded");
}
}
}
impl Historical for f64 {
fn walk(&mut self, w: &mut Walk<'_>) {
if !self.is_finite() {
w.error("historical real must be finite");
}
}
}
macro_rules! enums {
($($ty:ty),*) => { $( impl Historical for $ty {
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
} )* };
}
enums!(
ExchangeItemKind,
ExchangeMechanism,
ParameterDirection,
FieldStorage,
ComponentPortKind,
ComponentPortOrientation,
PortDirection,
ScenarioViewpoint,
ControlKind,
FragmentOperator,
TimeEventKind,
StateKind,
TransmissionProtocol,
AcquisitionProtocol,
TransitionKind,
StateActionKind,
ArtifactRelation,
ReferenceCollection,
OrderedCollection,
IncidentEnd,
DataConstructor,
PortConstructor,
OccurrenceConstructor,
EventConstructor,
MessageConstructor
);
impl<T: Historical> Historical for Option<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if let Some(v) = self {
v.walk(w);
}
}
}
impl<T: Historical> Historical for Vec<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
w.at(i, |w| v.walk(w));
}
if self.len() > MAX_LEAVES {
w.error("historical collection work limit exceeded");
}
}
}
impl<T: Historical> Historical for Observed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
Self::Value(v) => w.at("Value", |w| v.walk(w)),
}
}
}
impl<T: Historical> Historical for Reviewed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if matches!(
(&self.oa, &self.sa),
(Observed::NotApplicable, Observed::NotApplicable)
) {
w.error("a reviewed facet must apply on at least one side");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl<T: Historical> Historical for KeyPair<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.oa.is_none() && self.sa.is_none() {
w.error("key pair cannot be empty on both sides");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl Historical for AnnotationKey {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
}
}
impl Historical for MessageValue {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
w.at("expression", |w| self.expression.walk(w));
}
}
impl Historical for LifelineBinding {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("referenced", |w| self.referenced.walk(w));
w.at("local", |w| self.local.walk(w));
}
}
impl Historical for DataValue {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Real(value) => w.at("Real", |w| value.walk(w)),
Self::String(value) => w.at("String", |w| value.walk(w)),
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
Self::Boolean(_) | Self::Integer(_) => {}
}
}
}
impl Historical for TypeRef {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Named(id) => w.at("Named", |w| id.walk(w)),
_ => w.slot(HistoricalSourceKind::Enum),
}
}
}
impl Historical for ArtifactReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
Self::Name(v) => w.at("Name", |w| v.walk(w)),
Self::Description(v) => w.at("Description", |w| v.walk(w)),
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
}
}
}
macro_rules! family_visitor {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
impl Historical for FacetReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
}), )*
Self::Member { collection, keys, review } => w.at("Member", |w| {
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Order { collection, review } => w.at("Order", |w| {
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
}}
}),
Self::Literal { keys, review } => w.at("Literal", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Annotation { keys, review } => w.at("Annotation", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
}
}
}),
Self::Binding { keys, review } => w.at("Binding", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
}
}
}),
Self::Child { keys, review } => w.at("Child", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Incident { keys, end, review } => w.at("Incident", |w| {
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Artifact { keys, review } => w.at("Artifact", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
}
}
}
};
}
scalar_families!(family_visitor);
fn walk_baseline<'a>(
baseline: &mut SemanticBaseline,
visitor: &'a mut dyn FnMut(&mut Id),
) -> Walk<'a> {
let mut walk = Walk {
path: vec!["facets".into()],
slots: vec![],
errors: vec![],
leaves: 0,
visitor,
};
if baseline.facets.len() > MAX_FACTS {
walk.error("historical facet count limit exceeded");
}
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
walk.at(i, |w| facet.walk(w));
}
if walk.leaves >= MAX_LEAVES {
walk.error("historical reference work limit exceeded");
}
walk
}
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
pub fn visit_semantic_references(
baseline: &mut SemanticBaseline,
mut visitor: impl FnMut(&mut Id),
) {
walk_baseline(baseline, &mut visitor);
}
pub(crate) fn historical_source_slots(
baseline: &SemanticBaseline,
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
}
/// Checks persisted facts without demanding that historical references still exist.
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
let mut slots = BTreeSet::new();
for selector in baseline
.facets
.iter()
.take(MAX_FACTS)
.map(FacetReview::selector)
{
if selector_slots(&selector)
.into_iter()
.any(|slot| !slots.insert(slot))
{
errors.push(format!(
"duplicate or overlapping historical facet: {selector:?}"
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
let mut result = vec![];
if let Some(oa) = &keys.oa {
result.push(KeyPair {
oa: Some(oa.clone()),
sa: None,
});
}
if let Some(sa) = &keys.sa {
result.push(KeyPair {
oa: None,
sa: Some(sa.clone()),
});
}
result
}
match selector {
FacetSelector::Member { collection, keys } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Member {
collection: *collection,
keys,
})
.collect(),
FacetSelector::Literal(keys) => split(keys)
.into_iter()
.map(FacetSelector::Literal)
.collect(),
FacetSelector::Annotation(keys) => split(keys)
.into_iter()
.map(FacetSelector::Annotation)
.collect(),
FacetSelector::Binding(keys) => split(keys)
.into_iter()
.map(FacetSelector::Binding)
.collect(),
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
FacetSelector::Incident { keys, end } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Incident { keys, end: *end })
.collect(),
FacetSelector::Artifact { keys, facet } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Artifact {
keys,
facet: *facet,
})
.collect(),
value => vec![value.clone()],
}
}
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
a.oa.as_ref()
.zip(b.oa.as_ref())
.is_some_and(|(a, b)| a == b)
|| a.sa
.as_ref()
.zip(b.sa.as_ref())
.is_some_and(|(a, b)| a == b)
}
/// Slot identity is each actual side's key, not equality of the complete pair.
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
match (a, b) {
(
FacetSelector::Member {
collection: a,
keys: ak,
},
FacetSelector::Member {
collection: b,
keys: bk,
},
) => a == b && pair_overlap(ak, bk),
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
(
FacetSelector::Incident { keys: ak, end: a },
FacetSelector::Incident { keys: bk, end: b },
) => a == b && pair_overlap(ak, bk),
(
FacetSelector::Artifact { keys: ak, facet: a },
FacetSelector::Artifact { keys: bk, facet: b },
) => a == b && pair_overlap(ak, bk),
_ => a == b,
}
}
+174
View File
@@ -0,0 +1,174 @@
use super::*;
use crate::model::*;
use crate::transitions::Drift;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkedSemanticImpact {
pub objects: KeyPair<Id>,
pub traces: Vec<Id>,
pub drift: Drift,
pub unreviewed: bool,
}
const MAX_LINKED_OBJECTS: usize = 4_096;
const MAX_LINKED_EDGES: usize = 32_768;
/// Derived dependency diagnostics, never another historical baseline. Follow
/// direct references/children, then only reusable dictionary/event definitions.
/// Association endpoints and ordinary architecture neighborhoods do not expand.
pub fn linked_semantic_impacts(
project: &Project,
trace: &Trace,
references: &BTreeMap<Id, Id>,
) -> Result<Vec<LinkedSemanticImpact>, String> {
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for element in project.elements.values() {
if let Some(parent) = element.parent {
children.entry(parent).or_default().push(element.id);
}
}
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for relation in project.relations.values() {
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
contracts.entry(source).or_default().push(relation.id);
}
}
let direct = |id| -> Vec<Id> {
let mut out = vec![];
if let Some(element) = project.elements.get(&id) {
out.extend(
crate::references::element_references(element)
.into_iter()
.filter(|r| Some(*r) != element.parent),
);
}
if let Some(relation) = project.relations.get(&id) {
out.extend(crate::references::relation_references(relation));
}
out.extend(children.get(&id).into_iter().flatten().copied());
out.extend(contracts.get(&id).into_iter().flatten().copied());
out
};
let expandable = |id| {
project.elements.get(&id).is_some_and(|e| {
matches!(
e.kind(),
Kind::DataType
| Kind::DataField
| Kind::ExchangeItem
| Kind::Interface
| Kind::Event
)
}) || project
.relations
.get(&id)
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
};
let mut edges = 0usize;
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
let mut seen = BTreeSet::from([root]);
let first = direct(root);
edges += first.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
);
}
let mut queue: VecDeque<_> = first.into();
while let Some(id) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
if expandable(id) {
let next = direct(id);
edges += next.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
queue.extend(next);
}
}
seen.remove(&root);
Ok(seen)
};
let oa = traverse(trace.oa)?;
let sa = traverse(trace.sa)?;
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
for t in project.traces.values() {
traced.entry((t.oa, t.sa)).or_default().push(t);
}
let mut pairs = vec![];
let mut used = BTreeSet::new();
for id in oa {
let target = references.get(&id).copied();
if let Some(id) = target {
used.insert(id);
}
pairs.push(KeyPair {
oa: Some(id),
sa: target,
});
}
for id in sa.difference(&used) {
pairs.push(KeyPair {
oa: None,
sa: Some(*id),
});
}
let mut result = vec![];
for objects in pairs {
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
continue;
}
let links = objects
.oa
.zip(objects.sa)
.and_then(|pair| traced.get(&pair));
let mut flags = (false, false, links.is_none());
let mut ids = vec![];
for linked in links.into_iter().flatten() {
ids.push(linked.id);
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
if facts.is_empty() {
flags.2 = true;
}
for fact in facts {
match fact.drift {
Drift::OaChanged => flags.0 = true,
Drift::SaChanged => flags.1 = true,
Drift::BothChanged => {
flags.0 = true;
flags.1 = true;
}
Drift::Unreviewed => flags.2 = true,
_ => {}
}
}
}
let drift = match flags {
(true, true, _) => Drift::BothChanged,
(true, false, _) => Drift::OaChanged,
(false, true, _) => Drift::SaChanged,
(false, false, true) => Drift::Unreviewed,
_ => Drift::Unchanged,
};
result.push(LinkedSemanticImpact {
objects,
traces: ids,
drift,
unreviewed: flags.2,
});
}
Ok(result)
}
+592
View File
@@ -0,0 +1,592 @@
use super::catalog::{collection, order_collection, sides, write_scalar};
use super::*;
use crate::model::*;
use crate::transitions::TransitionDirection;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) fn set_collection(
p: &mut Project,
id: Id,
which: ReferenceCollection,
list: Vec<Id>,
) -> Result<(), String> {
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
return Err("Collection contains duplicate members".into());
}
use ReferenceCollection as C;
if which == C::Steps {
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
if let Some(steps) = e.steps_mut() {
*steps = list;
return Ok(());
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { occurrences, .. },
..
}) = e.shared_mut()
{
*occurrences = list;
return Ok(());
}
return Err("Target has no ordered steps".into());
}
let slot = match which {
C::Steps => unreachable!(),
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => Some(branches),
_ => None,
},
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) => Some(incoming_items),
_ => None,
},
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) => Some(outgoing_items),
_ => None,
},
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Event {
body: EventBody::Signal { items },
}) => Some(items),
_ => None,
},
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
) => Some(items),
_ => None,
},
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
_ => None,
},
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
_ => None,
},
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
_ => None,
},
}
.ok_or("Collection is inapplicable on target")?;
*slot = list;
Ok(())
}
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
match layer {
Layer::Oa => pair.oa.as_ref(),
Layer::Sa => pair.sa.as_ref(),
}
}
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
match match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
} {
Observed::Value(v) => Ok(v),
Observed::NotApplicable => {
Err("Facet does not exist in the selected constructor branch".into())
}
}
}
pub(crate) fn directional_map(
map: &BTreeMap<Id, Id>,
direction: TransitionDirection,
) -> Result<BTreeMap<Id, Id>, String> {
if direction == TransitionDirection::OaToSa {
return Ok(map.clone());
}
let mut inverse = BTreeMap::new();
for (oa, sa) in map {
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
return Err(format!(
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
));
}
}
Ok(inverse)
}
pub(crate) fn remap_record(
record: &mut SemanticRecord,
map: &BTreeMap<Id, Id>,
) -> Result<(), String> {
let mut missing = BTreeSet::new();
let mut remap = |id: &mut Id| {
if let Some(new) = map.get(id) {
*id = *new;
} else {
missing.insert(*id);
}
};
match record {
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
SemanticRecord::Artifact(a) => remap(&mut a.owner),
}
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"Missing explicit reference correspondence: {missing:?}"
))
}
}
pub(crate) fn remap_fact(
fact: &FacetReview,
map: &BTreeMap<Id, Id>,
source: Layer,
) -> Result<FacetReview, String> {
// Remap only the selected observation. Keys remain in canonical OA/SA space.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let base = SemanticBaseline {
facets: vec![fact.clone()],
};
let side = match source {
Layer::Oa => "oa",
Layer::Sa => "sa",
};
let mut missing = BTreeSet::new();
for (path, kind) in historical_source_slots(&base) {
if kind != HistoricalSourceKind::Reference {
continue;
}
let rest = &path[2..];
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
continue;
}
let mut leaf = &mut json;
for part in rest {
leaf = if leaf.is_array() {
leaf.get_mut(
part.parse::<usize>()
.map_err(|_| "Invalid historical array index")?,
)
} else {
leaf.get_mut(part)
}
.ok_or("Invalid historical source slot")?;
}
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
if let Some(new) = map.get(&id) {
*leaf = serde_json::to_value(new).unwrap();
} else {
missing.insert(id);
}
}
if !missing.is_empty() {
return Err(format!(
"Missing explicit reference correspondence: {missing:?}"
));
}
serde_json::from_value(json).map_err(|e| e.to_string())
}
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
&& map.get(&oa) != Some(&sa)
{
return Err("Key pair disagrees with frozen reference correspondence".into());
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty key pair".into());
}
Ok(())
}
match selector {
FacetSelector::Member { keys, .. }
| FacetSelector::Binding(keys)
| FacetSelector::Child(keys)
| FacetSelector::Incident { keys, .. }
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
FacetSelector::Annotation(keys) => {
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
if oa.path.len() != sa.path.len() {
return Err("Annotation path pairing disagrees".into());
}
for (oa, sa) in oa
.path
.iter()
.chain([&oa.field])
.zip(sa.path.iter().chain([&sa.field]))
{
pair(
&KeyPair {
oa: Some(*oa),
sa: Some(*sa),
},
map,
)?;
}
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty annotation pair".into());
}
Ok(())
}
FacetSelector::Literal(keys) => {
if keys
.oa
.as_ref()
.zip(keys.sa.as_ref())
.is_some_and(|(oa, sa)| oa != sa)
{
return Err(
"Enum literal identity is exact text; renaming is removal/addition".into(),
);
}
Ok(())
}
_ => Ok(()),
}
}
/// Apply just one selected fact to an in-memory expected project. The caller
/// subsequently prepares ordinary Commands and checks every induced difference.
pub(crate) fn write_fact(
p: &mut Project,
target: Id,
fact: &FacetReview,
from: Layer,
target_layer: Layer,
preserve_target_only: bool,
) -> Result<(), String> {
match fact {
FacetReview::Member {
collection: which,
keys,
review,
} => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer)
.copied()
.ok_or("Map the target collection member explicitly")?;
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|id| *id != key);
}
set_collection(p, target, *which, list)
}
FacetReview::Order {
collection: which,
review,
} => {
let supplied = value(review, from)?.clone();
let current = collection(p, target, order_collection(*which))
.ok_or("Inapplicable target order")?;
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
let current_set: BTreeSet<_> = current.iter().copied().collect();
if supplied_set != current_set {
if preserve_target_only
&& supplied_set.is_subset(&current_set)
&& current
.iter()
.filter(|id| supplied_set.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
}
set_collection(p, target, order_collection(*which), supplied)
}
FacetReview::Literal { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target is not an enumeration".into());
};
if present {
if !literals.contains(key) {
literals.push(key.clone());
}
} else {
literals.retain(|v| v != key);
}
Ok(())
}
FacetReview::Annotation { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
return Err(
"Annotation candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no message annotations".into());
};
values.retain(|v| AnnotationKey::from(v) != *key);
if let Some(row) = row {
values.push(row);
}
Ok(())
}
FacetReview::Binding { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| v.referenced != key) {
return Err(
"Binding candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no scenario bindings".into());
};
bindings.retain(|v| v.referenced != key);
if let Some(row) = row {
bindings.push(row);
}
Ok(())
}
FacetReview::Child { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
let e = p
.elements
.get_mut(key)
.ok_or("Target child does not exist; create it through transition mapping")?;
if *value(review, from)? {
e.parent = Some(target);
} else if e.parent == Some(target) {
e.parent = None;
}
Ok(())
}
FacetReview::Incident { keys, end, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
if !*value(review, from)? {
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
}
let relation = p
.relations
.get_mut(key)
.ok_or("Mapped incident relation is absent")?;
let (a, b) = relation.body.endpoints();
match end {
IncidentEnd::Source => relation.body.set_endpoints(target, b),
IncidentEnd::Target => relation.body.set_endpoints(a, target),
}
Ok(())
}
FacetReview::Artifact { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
if let ArtifactReview::Presence(review) = review {
if !*value(review, from)? {
p.artifact_links.remove(key);
return Ok(());
}
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
return Ok(());
}
return Err("Artifact creation requires a complete typed candidate".into());
}
let a = p
.artifact_links
.get_mut(key)
.ok_or("Artifact candidate is absent")?;
if a.owner != target {
return Err(
"Artifact metadata can only be reviewed on its currently attached owner trace"
.into(),
);
}
match review {
ArtifactReview::Presence(_) => unreachable!(),
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
}
Ok(())
}
_ => {
let (oa, sa) = sides(fact);
let side = match from {
Layer::Oa => oa,
Layer::Sa => sa,
};
let value = side
.get("Value")
.ok_or("Selected branch is not applicable")?
.clone();
write_scalar(p, target, &fact.selector(), value)
}
}
}
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
F::Data(DataFacet::Constructor) => {
matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
}
F::Port(PortFacet::Constructor) => matches!(
facet,
F::Port(_)
| F::Member {
collection: ReferenceCollection::FunctionInputItems
| ReferenceCollection::FunctionOutputItems,
..
}
),
F::Occurrence(OccurrenceFacet::Constructor) => {
matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|| matches!(
facet,
F::Annotation(_)
| F::Binding(_)
| F::Member {
collection: ReferenceCollection::Steps
| ReferenceCollection::FragmentBranches,
..
}
| F::Order(_)
)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
F::Event(_)
| F::Member {
collection: ReferenceCollection::SignalItems,
..
}
),
F::State(StateFacet::Kind) => facet == group,
_ => false,
}
}
pub(crate) fn copy_constructor(
p: &mut Project,
target: Id,
source: &SemanticRecord,
group: &FacetSelector,
) -> Result<(), String> {
let SemanticRecord::Element(source) = source else {
return Err("Constructor source must be an element".into());
};
let target = p
.elements
.get_mut(&target)
.ok_or("Missing target element")?;
let a = source.shared().ok_or("Source has no shared constructor")?;
let b = target
.shared_mut()
.ok_or("Target has no shared constructor")?;
match (group, a, b) {
(
FacetSelector::Data(DataFacet::Constructor),
SharedBody::DataType {
definition,
default,
..
},
SharedBody::DataType {
definition: d,
default: v,
..
},
) => {
*d = definition.clone();
*v = default.clone();
}
(
FacetSelector::Port(PortFacet::Constructor),
SharedBody::Port { contract, legacy },
SharedBody::Port {
contract: c,
legacy: l,
},
) => {
*c = contract.clone();
*l = legacy.clone();
}
(
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
SharedBody::Occurrence { body, .. },
SharedBody::Occurrence { body: b, .. },
) => *b = body.clone(),
(
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
},
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: k, ..
},
..
},
) => *k = message_kind.clone(),
(
FacetSelector::Event(EventFacet::Constructor),
SharedBody::Event { body },
SharedBody::Event { body: b },
) => *b = body.clone(),
(
FacetSelector::State(StateFacet::Kind),
SharedBody::State { kind, .. },
SharedBody::State { kind: k, .. },
) => *k = *kind,
_ => return Err("Constructor family mismatch".into()),
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
use crate::model::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Observed<T> {
NotApplicable,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reviewed<T> {
pub oa: Observed<T>,
pub sa: Observed<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair<K> {
pub oa: Option<K>,
pub sa: Option<K>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnnotationKey {
pub path: Vec<Id>,
pub field: Id,
}
impl From<&MessageValue> for AnnotationKey {
fn from(value: &MessageValue) -> Self {
Self {
path: value.path.clone(),
field: value.field,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataConstructor {
Boolean,
Integer,
Real,
String,
Enumeration,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortConstructor {
FunctionInput,
FunctionOutput,
Component,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OccurrenceConstructor {
Exchange,
Action,
Control,
SequenceReference,
ScenarioUse,
State,
Fragment,
Branch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventConstructor {
Signal,
Time,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageConstructor {
Asynchronous,
Call,
Reply,
Create,
Delete,
Timer,
}
// This finite catalog is also consumed by the typed historical leaf visitor.
// Each selector fixes its payload type; there is no arbitrary value/path schema.
macro_rules! scalar_families {
($m:ident) => { $m! {
Header, HeaderFacet, HeaderReview {
Name: String, Description: String, Parent: Option<Id>
}
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
}
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
Data, DataFacet, DataReview {
Constructor: DataConstructor,
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
}
Field, FieldFacet, FieldReview {
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
}
Port, PortFacet, PortReview {
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
LegacyInterface: Option<Id>
}
Scenario, ScenarioFacet, ScenarioReview {
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
}
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
Occurrence, OccurrenceFacet, OccurrenceReview {
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
}
Event, EventFacet, EventReview {
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
}
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
Relation, RelationFacet, RelationReview {
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
Guard: String, Effect: String, StateActionPhase: StateActionKind
}
}};
}
pub(super) use scalar_families;
macro_rules! define_families {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
$(
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum $selector { $( $leaf ),* }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
)*
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetSelector {
$( $family($selector), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
Order(OrderedCollection),
Literal(KeyPair<String>),
Annotation(KeyPair<AnnotationKey>),
Binding(KeyPair<Id>),
Child(KeyPair<Id>),
Incident { keys: KeyPair<Id>, end: IncidentEnd },
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetReview {
$( $family($review), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
}
impl FacetReview {
pub fn selector(&self) -> FacetSelector {
match self {
$( Self::$family(value) => FacetSelector::$family(match value {
$( $review::$leaf(_) => $selector::$leaf, )*
}), )*
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
Self::Order { collection, .. } => FacetSelector::Order(*collection),
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
}
}
}
};
}
scalar_families!(define_families);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ReferenceCollection {
Steps,
FragmentBranches,
FunctionInputItems,
FunctionOutputItems,
SignalItems,
ExchangeItems,
TransitionTriggers,
TransitionEffects,
Constrained,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum OrderedCollection {
Steps,
FragmentBranches,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum IncidentEnd {
Source,
Target,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArtifactFacet {
Presence,
Name,
Description,
Owner,
Relation,
Uri,
Revision,
Fragment,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ArtifactReview {
Presence(Reviewed<bool>),
Name(Reviewed<String>),
Description(Reviewed<String>),
Owner(Reviewed<Id>),
Relation(Reviewed<ArtifactRelation>),
Uri(Reviewed<String>),
Revision(Reviewed<Option<String>>),
Fragment(Reviewed<Option<String>>),
}
impl ArtifactReview {
pub fn facet(&self) -> ArtifactFacet {
match self {
Self::Presence(_) => ArtifactFacet::Presence,
Self::Name(_) => ArtifactFacet::Name,
Self::Description(_) => ArtifactFacet::Description,
Self::Owner(_) => ArtifactFacet::Owner,
Self::Relation(_) => ArtifactFacet::Relation,
Self::Uri(_) => ArtifactFacet::Uri,
Self::Revision(_) => ArtifactFacet::Revision,
Self::Fragment(_) => ArtifactFacet::Fragment,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticBaseline {
pub facets: Vec<FacetReview>,
}
+1 -1
View File
@@ -999,7 +999,7 @@ mod transaction_tests {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
)
.unwrap();
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
+50 -6
View File
@@ -1,6 +1,6 @@
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
//! documents; this module never reads a filesystem, URL, or process directory.
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -280,7 +280,7 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
}
}
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
/// Missing/unreachable files and every admission or engineering error leave the
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
pub fn compile_bundle(
@@ -720,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
Ok(())
}
/// Canonical, readable source4 export. Like `language::format`, the caller is
/// Canonical, readable current-version export. Like `language::format`, the caller is
/// responsible for supplying a valid model; compilation checks the result.
/// Existing source layout is preserved by `update_bundle`, not this formatter.
pub fn format_bundle(project: &Project) -> SourceBundle {
@@ -736,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia 4;\n{}{{\n description {};\n",
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
@@ -926,6 +926,7 @@ pub fn update_bundle_with_identity_map(
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
@@ -955,6 +956,7 @@ pub fn update_bundle_with_identity_map(
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let mut local = Vec::new();
update.header(
@@ -1126,6 +1128,7 @@ struct SyntaxUpdate<'a> {
// Source3 migration only adds path qualification; the existing field identity
// must keep its authored argument trivia during that one-time transformation.
legacy_value_paths: bool,
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn header(
@@ -1221,6 +1224,18 @@ impl SyntaxUpdate<'_> {
alias.clone(),
));
}
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
.pop()
.expect("historical UUID string");
edits.push((
Span {
start: actual.span.start + token.span.start,
end: actual.span.start + token.span.end,
},
quote(&id.to_string()),
));
}
(EditKind::Atom, EditKind::Atom) => edits.push((
actual.span,
self.after[after.span.start..after.span.end].into(),
@@ -1345,11 +1360,27 @@ impl SyntaxUpdate<'_> {
let previous = before.unwrap_or(&[]);
let old_keys: Option<Vec<_>> = previous
.iter()
.map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
.map(|item| {
item_key(
item,
self.before,
self.old_aliases,
self.legacy_value_paths,
None,
)
})
.collect();
let new_keys: Option<Vec<_>> = after
.iter()
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
.map(|item| {
item_key(
item,
self.after,
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
)
})
.collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len()
@@ -1457,9 +1488,22 @@ fn item_key(
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
) -> Option<String> {
use language::EditKind;
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
origins.unwrap_or(&BTreeMap::new()),
)
{
return Some(key);
}
match &value.kind {
EditKind::HistoricalUuid(id) => Some(format!(
"uuid:{}",
origins.and_then(|map| map.get(id)).unwrap_or(id)
)),
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
EditKind::Atom => Some(format!(
"literal:{}",
@@ -1,4 +1,4 @@
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
use super::*;
pub(super) fn upgrade(
@@ -15,6 +15,7 @@ pub(super) fn upgrade(
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
.filter(|default| default.field != "baseline.semantic")
.map(|default| default.object)
.collect();
let aliases: BTreeMap<_, _> = project
@@ -61,6 +62,7 @@ pub(super) fn upgrade(
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
identity_origins: None,
};
update.value(
&actual.fields,
@@ -78,7 +80,7 @@ pub(super) fn upgrade(
edits
.entry(current.bundle.entry.clone())
.or_default()
.push((version, "4".into()));
.push((version, crate::SCHEMA_VERSION.to_string()));
// New allocation relations are emitted into the allocating interface's
// existing module, outside its lexical element body. Existing source never moves.
let mut added: BTreeMap<String, String> = BTreeMap::new();
@@ -117,7 +119,7 @@ pub(super) fn upgrade(
&bundle.entry,
Span::default(),
"migration_source_mismatch",
"Targeted source3 upgrade did not reproduce the migrated project.",
"Targeted source upgrade did not reproduce the migrated project.",
)]);
}
result.decoded.migration = Some(report.clone());
+13 -1
View File
@@ -202,9 +202,15 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
return None;
}
if let Some(shared) = e.shared()
&& kind == e.kind()
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
{
let mut shared = shared.clone();
if let SharedBody::State {
kind: state_kind, ..
} = &mut shared
{
*state_kind = kind.state_kind()?;
}
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
*viewpoint = match (*viewpoint, layer) {
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
@@ -927,6 +933,7 @@ pub fn plan_transition(
.unwrap_or(TraceBaseline {
oa: current_oa.clone(),
sa: current_sa.clone(),
semantic: None,
});
for field in &reviewed_fields {
set(&mut baseline.oa, *field, value(&current_oa, *field).clone());
@@ -1081,6 +1088,11 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
}
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
// State kind is a reviewed structural facet of one state family. An existing
// counterpart remains selectable after either side changes its vertex kind.
if source.is_state() && target.is_state() {
return true;
}
if matches!(
(direction, source, target),
(
+13
View File
@@ -876,6 +876,19 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, t.id, "baseline.name", &props.name);
text(&mut out, t.id, "baseline.description", &props.description);
}
if let Some(semantic) = &b.semantic
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
{
for message in errors {
error(
&mut out,
t.id,
"semantic_baseline",
"baseline.semantic",
message,
);
}
}
}
match crate::transitions::trace_drift(p, t) {
crate::transitions::Drift::Unchanged => {}
+27
View File
@@ -0,0 +1,27 @@
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
arcadia 4;
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
description "Preserve the original review without inventing semantic history.";
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
description "Unreviewed OA description";
definition { Integer { minimum 0; maximum 100; }; };
default { Integer 20; };
unit "m";
}
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
description "Unreviewed SA description";
definition { Real { minimum 0.0; maximum 100.0; }; };
default { Real 20.0; };
unit "ft";
}
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
description "The target unit and representation are intentional.";
oa @oa_dimension;
sa @sa_dimension;
kind Item;
baseline {
oa { name "Approved OA name"; description "Approved OA\nprose"; };
sa { name "Approved SA name"; description "Approved SA prose"; };
};
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"schema_version": 4,
"id": "40000000-0000-4000-8000-000000000001",
"alias": "reviewed_prose",
"name": "Reviewed dimensions",
"description": "Preserve the original review without inventing semantic history.",
"elements": {
"40000000-0000-4000-8000-000000000002": {
"id": "40000000-0000-4000-8000-000000000002",
"alias": "oa_dimension",
"name": "OA dimension now",
"description": "Unreviewed OA description",
"parent": null,
"body": {"Oa": {"Shared": {"DataType": {
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
"default": {"Integer": 20},
"unit": "m"
}}}}
},
"40000000-0000-4000-8000-000000000003": {
"id": "40000000-0000-4000-8000-000000000003",
"alias": "sa_dimension",
"name": "SA dimension now",
"description": "Unreviewed SA description",
"parent": null,
"body": {"Sa": {"Shared": {"DataType": {
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
"default": {"Real": 20.0},
"unit": "ft"
}}}}
}
},
"relations": {},
"diagrams": {},
"artifact_links": {},
"traces": {
"40000000-0000-4000-8000-000000000004": {
"id": "40000000-0000-4000-8000-000000000004",
"alias": "dimension_review",
"name": "Dimension rationale",
"description": "The target unit and representation are intentional.",
"oa": "40000000-0000-4000-8000-000000000002",
"sa": "40000000-0000-4000-8000-000000000003",
"kind": "Item",
"baseline": {
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
}
}
}
}
+4 -3
View File
@@ -491,7 +491,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
#[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [
("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
@@ -795,7 +795,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 4);
assert_eq!(retained["schema_version"], 5);
retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
@@ -991,7 +991,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, 4);
assert_eq!(from_text.schema_version, 5);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() {
@@ -1193,6 +1193,7 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;")
|| code.trim_start().starts_with("arcadia 4;")
|| code.trim_start().starts_with("arcadia 5;")
{
read(&code);
examples += 1;
+3 -3
View File
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 4));
assert_eq!((report.from_schema, report.to_schema), (2, 5));
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
@@ -144,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
}
}
let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 4;"));
assert!(canonical.starts_with("arcadia 5;"));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project);
@@ -274,7 +274,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 4);
assert_eq!(result.project.schema_version, 5);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
+2 -1
View File
@@ -29,7 +29,8 @@ project typed_ports "Typed ports" {
#[test]
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
let decoded = language::parse_with_report(SOURCE).unwrap();
assert!(decoded.migration.is_none());
let migration = decoded.migration.as_ref().unwrap();
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
let canonical = language::format(&decoded.project);
for field in [
"incoming_items [@data]",
+680
View File
@@ -0,0 +1,680 @@
use oa_core::{transitions::*, *};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Family {
Data,
Port,
Occurrence,
Message,
Event,
State,
}
const FAMILIES: [Family; 6] = [
Family::Data,
Family::Port,
Family::Occurrence,
Family::Message,
Family::Event,
Family::State,
];
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
p.add_element(kind, layer, "reviewed", parent).unwrap()
}
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("review_{}", id.simple()),
name: "Review".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
id
}
fn set(p: &mut Project, id: Id, body: SharedBody) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
}
fn integer(p: &mut Project, id: Id) {
set(
p,
id,
SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(10),
},
default: None,
unit: "m".into(),
},
);
}
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let id = match family {
Family::Data => {
let id = add(&mut p, Kind::DataType, layer, None);
integer(&mut p, id);
id
}
Family::Port => {
let kind = if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let owner = add(&mut p, kind, layer, None);
let id = Id::new_v4();
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
});
}
p.elements.insert(id, port);
id
}
Family::Occurrence | Family::Message => {
let owner = add(&mut p, Kind::Scenario, layer, None);
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
set(
&mut p,
id,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Timer {
expression: "10s".into(),
},
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
},
);
id
}
Family::Event => {
let id = add(&mut p, Kind::Event, layer, None);
set(
&mut p,
id,
SharedBody::Event {
body: EventBody::Time {
kind: TimeEventKind::After,
expression: "10s".into(),
},
},
);
id
}
Family::State => {
let machine = add(&mut p, Kind::StateMachine, layer, None);
let region = add(&mut p, Kind::Region, layer, Some(machine));
add(&mut p, Kind::State, layer, Some(region))
}
};
pair.push(id);
}
let (oa, sa) = (pair[0], pair[1]);
let t = trace(&mut p, oa, sa);
let (constructor, descendant) = match family {
Family::Data => (
FacetSelector::Data(DataFacet::Constructor),
FacetSelector::Data(DataFacet::IntegerMaximum),
),
Family::Port => (
FacetSelector::Port(PortFacet::Constructor),
FacetSelector::Port(PortFacet::LegacyDirection),
),
Family::Occurrence => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Message => (
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Event => (
FacetSelector::Event(EventFacet::Constructor),
FacetSelector::Event(EventFacet::TimeExpression),
),
Family::State => (
FacetSelector::State(StateFacet::Kind),
FacetSelector::State(StateFacet::Entry),
),
};
(p, oa, sa, t, constructor, descendant)
}
fn change_descendant(p: &mut Project, id: Id) {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::DataType {
definition: DataTypeDefinition::Integer { maximum, .. },
..
} => *maximum = Some(20),
SharedBody::Port {
legacy: Some(LegacyPortContract { direction, .. }),
..
} => *direction = PortDirection::Out,
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Timer { expression },
..
},
..
}
| SharedBody::Event {
body: EventBody::Time { expression, .. },
} => *expression = "20s".into(),
SharedBody::State { entry, .. } => *entry = "initialize".into(),
_ => panic!("Unexpected test family"),
}
}
fn request(
oa: Id,
sa: Id,
reverse: bool,
resolutions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let (direction, source, target) = if reverse {
(TransitionDirection::SaToOa, sa, oa)
} else {
(TransitionDirection::OaToSa, oa, sa)
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut r = SemanticTransitionRequest::new(transition);
r.reviews.push(SemanticReviewRequest {
source,
resolutions: resolutions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
r
}
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
let plan = plan_semantic_transition(p, r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
(apply_semantic_transition(p, &plan).unwrap(), plan)
}
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.find(|f| f.selector == *selector)
.unwrap()
.drift
}
#[test]
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
for family in FAMILIES {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
change_descendant(&mut p, target);
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
if decision == SemanticDecision::Candidate {
r.candidates
.push(SemanticRecord::Element(p.elements[&target].clone()));
}
let (q, plan) = apply(&p, r.clone());
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
assert!(plan.reviews[0].retired.is_empty());
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(facts.iter().all(|f| f.drift
== if f.selector == constructor {
Drift::Unchanged
} else {
Drift::Unreviewed
}));
let (mut q, _) = apply(
&q,
request(
oa,
sa,
reverse,
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
),
);
change_descendant(&mut q, source);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let repeated = plan_semantic_transition(&q, r).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
}
}
}
}
#[test]
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
for family in FAMILIES {
for reverse in [false, true] {
for history in 0..3 {
let (mut p, oa, sa, t, _, _) = fixture(family);
if history == 0 {
p.traces.get_mut(&t).unwrap().baseline = None;
} else if history == 2 {
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline::default());
}
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| f.selector)
.collect();
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
selectors
.iter()
.cloned()
.map(|s| (s, SemanticDecision::KeepTarget))
.collect(),
),
);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
assert!(plan.reviews[0].retired.is_empty());
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
assert!(!status.own_unreviewed);
}
}
}
}
#[test]
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
let json = include_str!("fixtures/v4-reviewed-prose.json")
.replace("\"Real\"", "\"Integer\"")
.replace("0.0", "0")
.replace("100.0", "100")
.replace("20.0", "20");
for reverse in [false, true] {
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
let p = decoded.project;
let t = p.traces.values().next().unwrap();
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
let original_prose = t.baseline.clone().unwrap();
let (oa, sa, id) = (t.oa, t.sa, t.id);
let selections = semantic_facets(&p, t, &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| (f.selector, SemanticDecision::KeepTarget))
.collect();
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
assert_eq!(q.elements, p.elements);
let baseline = q.traces[&id].baseline.as_ref().unwrap();
assert_eq!(baseline.oa, original_prose.oa);
assert_eq!(baseline.sa, original_prose.sa);
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged);
assert!(!status.own_unreviewed);
}
}
#[test]
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let target = if reverse { oa } else { sa };
let mut changed = p.clone();
change_descendant(&mut changed, target);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::Candidate)],
);
r.candidates
.push(SemanticRecord::Element(changed.elements[&target].clone()));
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
invalid
.conflicts
.iter()
.any(|c| c.contains("changes an unselected facet"))
);
assert!(apply_semantic_transition(&p, &invalid).is_err());
r.reviews[0].resolutions.push(FacetResolution {
facet: descendant.clone(),
decision: SemanticDecision::Candidate,
});
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, changed.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
assert_eq!(
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
Drift::Unreviewed
);
}
}
#[test]
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let (mut p, _) = apply(
&p,
request(
oa,
sa,
reverse,
vec![
(constructor.clone(), SemanticDecision::KeepTarget),
(descendant.clone(), SemanticDecision::KeepTarget),
],
),
);
let original = p.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.clone();
change_descendant(&mut p, if reverse { sa } else { oa });
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor.clone());
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
assert!(plan.reviews[0].retired.is_empty());
assert_eq!(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
&original
);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
}
}
#[test]
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
for switch in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
integer(&mut p, second);
if switch {
set(
&mut p,
second,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(30.),
},
default: None,
unit: "m".into(),
},
);
} else {
change_descendant(&mut p, second);
}
trace(&mut p, second, sa);
let mut r = request(
oa,
sa,
false,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.transition.sources.push(second);
r.transition.mappings.push(TransitionMapping {
source: second,
target: Some(sa),
target_kind: None,
});
r.reviews.push(SemanticReviewRequest {
source: second,
reset: vec![],
resolutions: vec![FacetResolution {
facet: if switch {
constructor.clone()
} else {
descendant.clone()
},
decision: SemanticDecision::TakeSource,
}],
});
for reversed in [false, true] {
let mut r = r.clone();
if reversed {
r.reviews.reverse();
}
let plan = plan_semantic_transition(&p, r).unwrap();
if switch {
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
assert!(apply_semantic_transition(&p, &plan).is_err());
} else {
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_semantic_transition(&p, &plan).unwrap();
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
assert!(matches!(
q.elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
maximum: Some(20),
..
},
..
})
));
}
}
}
}
#[test]
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
for reverse in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
let source = if reverse { sa } else { oa };
let target = if reverse { oa } else { sa };
let mut action = p.elements[&source].shared().unwrap().clone();
if let SharedBody::Occurrence { body, .. } = &mut action {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
set(&mut p, source, action.clone());
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
),
);
// One-sided applicability still has a constructor-group scope and records its timer leaf.
assert_eq!(
plan.reviews[0].acknowledged,
vec![constructor.clone(), descendant.clone()]
);
assert_eq!(q.elements, p.elements);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
let mut p = q;
set(&mut p, target, action);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor);
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(apply_semantic_transition(&p, &plan).is_err());
}
}
#[test]
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let keep = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
let (mut p, _) = apply(&p, keep.clone());
set(
&mut p,
source,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(20.),
},
default: None,
unit: "m".into(),
},
);
let (mut q, plan) = apply(&p, keep.clone());
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
assert!(plan.reviews[0].acknowledged.contains(&maximum));
assert!(
plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Default))
);
assert!(
!plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Unit))
);
assert_eq!(q.elements, p.elements);
if let Some(SharedBody::DataType {
definition: DataTypeDefinition::Real { maximum, .. },
..
}) = q.elements.get_mut(&source).unwrap().shared_mut()
{
*maximum = Some(30.);
}
let repeated = plan_semantic_transition(&q, keep).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
assert_eq!(
drift(&q, t, &maximum),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let take = request(
oa,
sa,
reverse,
vec![(constructor, SemanticDecision::TakeSource)],
);
let (p, _) = apply(&q, take.clone());
assert!(matches!(
p.elements[&target].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Real {
maximum: Some(30.),
..
},
..
})
));
let completed = plan_semantic_transition(&p, take).unwrap();
assert!(
completed
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&p, &completed).is_err());
}
}
+169
View File
@@ -0,0 +1,169 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::Value;
use std::collections::BTreeMap;
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
#[test]
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
let json = persistence::decode_with_report(JSON4).unwrap();
let source = language::parse_with_report(SOURCE4).unwrap();
assert_eq!(json.project, source.project);
for decoded in [json, source] {
assert_eq!(decoded.project.schema_version, 5);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
let trace = decoded.project.traces.values().next().unwrap();
let baseline = trace.baseline.as_ref().unwrap();
assert!(baseline.semantic.is_none());
assert_eq!(baseline.oa.name, "Approved OA name");
assert_eq!(baseline.oa.description, "Approved OA\nprose");
assert_eq!(baseline.sa.name, "Approved SA name");
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
expected["schema_version"] = 5.into();
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
let reopened =
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
.unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, decoded.project);
}
}
#[test]
fn older_headers_cannot_admit_new_semantic_baselines() {
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
let trace = value["traces"]
.as_object_mut()
.unwrap()
.values_mut()
.next()
.unwrap();
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
for version in [3, 4] {
value["schema_version"] = version.into();
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
assert!(
language::parse(&source)
.unwrap_err()
.iter()
.any(|d| d.message.contains("require schema/source5"))
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap_err()
.iter()
.any(|d| d.diagnostic.message.contains("require schema/source5"))
);
}
#[test]
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
let json = std::str::from_utf8(JSON4).unwrap();
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("duplicate field")
);
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
assert!(
persistence::decode(unknown.as_bytes())
.unwrap_err()
.to_string()
.contains("unknown field")
);
}
#[test]
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
let start = SOURCE4.find(" oa_data_type").unwrap();
let end = SOURCE4.rfind('}').unwrap();
let entry = format!(
"{} include \"contracts.arcadia\";\n}}\n",
&SOURCE4[..start]
);
let mut module =
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
module.push_str(
r#"
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
definition { Record { supertype none; }; };
// This field remains inside its authored record.
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
}
"#,
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry.clone()),
("contracts.arcadia".into(), module.clone()),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
assert_eq!(
compiled.bundle.files["project.arcadia"],
entry.replacen("arcadia 4;", "arcadia 5;", 1)
);
assert_eq!(
compiled.index.lexical_owners
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert!(reopened.decoded.migration.is_none());
assert_eq!(reopened.bundle, compiled.bundle);
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(
bundle.files["project.arcadia"], entry,
"caller draft is unchanged"
);
}
#[test]
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
for (version, bytes) in [
(
1,
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
),
(
2,
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
),
(
3,
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
),
] {
let decoded = persistence::decode_with_report(bytes).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (version, 5));
assert_eq!(decoded.project.schema_version, 5);
assert!(
decoded
.project
.traces
.values()
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
);
assert_eq!(
language::parse(&language::format(&decoded.project)).unwrap(),
decoded.project
);
}
}
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
use oa_core::{language, persistence, semantic::*, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
Reviewed {
oa: Observed::Value(oa),
sa: Observed::Value(sa),
}
}
fn project(facets: Vec<FacetReview>) -> Project {
let mut project =
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets });
project
}
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
&mut project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets
}
fn compile(source: String) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
},
ProjectLimits::default(),
)
.unwrap()
}
#[test]
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
let row = MessageValue {
path: vec![id(91), id(92)],
field: id(93),
expression: "x + 1".into(),
};
let model = project(vec![
FacetReview::Data(DataReview::Constructor(reviewed(
DataConstructor::Integer,
DataConstructor::Real,
))),
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
oa: Observed::Value(None),
sa: Observed::NotApplicable,
})),
FacetReview::Data(DataReview::RealMaximum(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::Value(Some(1.5)),
})),
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
))),
FacetReview::Field(FieldReview::Default(reviewed(
Some(vec![DataValue::String(id(90).to_string())]),
None,
))),
FacetReview::Member {
collection: ReferenceCollection::Steps,
keys: KeyPair {
oa: Some(id(91)),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Order {
collection: OrderedCollection::Steps,
review: reviewed(vec![id(91), id(92)], vec![]),
},
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Binding {
keys: KeyPair {
oa: Some(id(94)),
sa: None,
},
review: reviewed(
Some(LifelineBinding {
referenced: id(94),
local: id(95),
}),
None,
),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(90).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Artifact {
keys: KeyPair {
oa: Some(id(96)),
sa: None,
},
review: ArtifactReview::Relation(reviewed(
ArtifactRelation::References,
ArtifactRelation::References,
)),
},
]);
assert!(!model.contains(id(90)));
let source = language::format(&model);
assert!(source.starts_with("arcadia 5;"));
assert!(
source.contains(&format!("Named uuid \"{}\"", id(90))),
"{source}"
);
assert!(source.contains("sa NotApplicable;"));
assert!(source.contains("Value none;"));
assert!(source.contains(&format!("String \"{}\"", id(90))));
assert_eq!(language::parse(&source).unwrap(), model);
assert_eq!(compile(source).decoded.project, model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
}
#[test]
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
let model = project(vec![
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
]);
let source = language::format(&model);
let reference = format!("Named uuid \"{}\"", id(2));
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
let invalid = source.replace(&reference, &replacement);
assert!(language::parse(&invalid).is_err(), "{invalid}");
assert!(
compile_bundle(
&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), invalid)])
},
ProjectLimits::default()
)
.is_err()
);
}
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_text).is_err());
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
assert!(language::parse(&invalid_enum).is_err());
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_live).is_err());
let invalid_prose = source.replace(
"name \"Approved OA name\";",
&format!("name uuid \"{}\";", id(2)),
);
assert!(language::parse(&invalid_prose).is_err());
// `uuid` remains an ordinary legal declaration alias after @.
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
}
#[test]
fn semantic_values_remain_closed_typed_records_in_both_formats() {
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
reviewed(Some(0), None),
))]);
let source = language::format(&model);
for invalid in [
source.replace("IntegerMinimum", "UnknownFacet"),
source.replace("Value 0;", "Value \"0\";"),
source.replace("facets [", "unexpected true; facets ["),
] {
assert!(language::parse(&invalid).is_err(), "{invalid}");
}
let mut json = serde_json::to_value(&model).unwrap();
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
}
#[test]
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
let row = MessageValue {
path: vec![id(2)],
field: id(90),
expression: id(2).to_string(),
};
let model = project(vec![
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(2).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
]);
let source = language::format(&model)
.replace("Annotation {", "Annotation { // annotation rationale")
.replace("Literal {", "Literal { // literal rationale")
.replace("Named uuid ", "Named uuid // historical type rationale\n");
let current = compile(source);
let mut prepared = prepare_command(
&current.decoded.project,
&Command::Reidentify(id(2), id(20)),
)
.unwrap();
facets(&mut prepared.project).reverse();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let text = &updated.bundle.files["project.arcadia"];
assert_eq!(updated.decoded.project, prepared.project);
assert!(
text.contains(&format!(
"Named uuid // historical type rationale\n\"{}\"",
id(20)
)),
"{text}"
);
let type_note = text.find("// historical type rationale").unwrap();
let literal_note = text.find("// literal rationale").unwrap();
let annotation_note = text.find("// annotation rationale").unwrap();
assert!(
type_note < literal_note && literal_note < annotation_note,
"{text}"
);
assert!(text.contains(&format!("expression \"{}\"", id(2))));
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
panic!()
};
assert_eq!(
keys.oa,
Some(id(2).to_string()),
"UUID-shaped literal keys are text"
);
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.bundle,
updated.bundle
);
}
#[test]
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let mut former_type = model.elements[&id(2)].clone();
former_type.id = id(90);
former_type.alias = "former_type".into();
model.elements.insert(id(90), former_type);
let current = compile(language::format(&model));
let prepared = prepare_command(&current.decoded.project, &Command::Delete(id(90))).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let source = &updated.bundle.files["project.arcadia"];
assert!(!updated.decoded.project.contains(id(90)));
assert!(!source.contains("@former_type"));
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
assert_eq!(updated.decoded.project.traces, model.traces);
assert_eq!(language::parse(source).unwrap(), prepared.project);
}
#[test]
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
use oa_core::source_files::{load_project, save_project};
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let source = language::format(&model);
let trace_at = source.find(" trace ").unwrap();
let end = source.rfind('}').unwrap();
let entry = format!(
"{} include \"reviews.arcadia\";\n}}\n",
&source[..trace_at]
);
let review =
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
let directory = tempfile::tempdir().unwrap();
let entry_path = directory.path().join("project.arcadia");
std::fs::write(&entry_path, &entry).unwrap();
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
let mut changed = compiled.decoded.project.clone();
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
panic!()
};
value.sa = Observed::Value(TypeRef::String);
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.bundle.files["project.arcadia"], entry);
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
assert_eq!(saved.bundle, reopened.bundle);
assert_eq!(
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
assert!(!changed.contains(id(90)));
}
#[test]
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
let model = project(vec![]);
let current = compile(language::format(&model).replace(
"baseline {",
"baseline { // prior prose review is independent",
));
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
transition.mappings.push(TransitionMapping {
source: id(2),
target: Some(id(3)),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source: id(2),
resolutions: vec![
FacetResolution {
facet: FacetSelector::Data(DataFacet::Unit),
decision: SemanticDecision::KeepTarget,
},
FacetResolution {
facet: FacetSelector::Header(HeaderFacet::Name),
decision: SemanticDecision::TakeSource,
},
],
reset: vec![],
});
let plan = plan_semantic_transition(&current.decoded.project, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let prepared = prepare_semantic_transition(&current.decoded.project, &plan).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(
updated.decoded.project.elements[&id(3)].name,
"OA dimension now"
);
assert!(
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
);
assert!(
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
);
let baseline = updated.decoded.project.traces[&id(4)]
.baseline
.as_ref()
.unwrap();
assert_eq!(baseline.oa.name, "Approved OA name");
assert!(
baseline
.semantic
.as_ref()
.unwrap()
.facets
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
"m".into(),
"ft".into()
))))
);
assert_eq!(
language::parse(&language::format(&updated.decoded.project)).unwrap(),
prepared.project
);
assert_eq!(
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
prepared.project
);
}
+4 -4
View File
@@ -21,7 +21,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
assert_eq!(decoded.migration, source.migration);
let project = &decoded.project;
let report = decoded.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 4));
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(project.identities().len(), 19);
assert!(report.preserved_ports.is_empty());
assert!(report.generated_endpoints.is_empty());
@@ -71,7 +71,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
let canonical = language::format(project);
assert!(canonical.starts_with("arcadia 4;"));
assert!(canonical.starts_with("arcadia 5;"));
let reopened = language::parse_with_report(&canonical).unwrap();
assert_eq!(&reopened.project, project);
assert!(reopened.migration.is_none());
@@ -154,12 +154,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(v2.project, text.project);
assert_eq!(v2.migration, text.migration);
assert_eq!(v2.migration.unwrap().to_schema, 4);
assert_eq!(v2.migration.unwrap().to_schema, 5);
let v1 = language::parse_with_report(
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
)
.unwrap();
assert_eq!(v1.project.schema_version, 4);
assert_eq!(v1.project.schema_version, 5);
assert_eq!(v1.migration.unwrap().from_schema, 1);
}
+1 -1
View File
@@ -26,7 +26,7 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files.len(), 2);
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;"));
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
for text in [
"// Contracts stay in their original module.",
+6 -6
View File
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
)
.unwrap();
fs::create_dir(directory.path().join("parts")).unwrap();
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(
directory.path().join("parts/a.arcadia"),
"oa_activity a \"A\" { invalid",
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
let overlays = BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
),
(
"parts/a.arcadia".into(),
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
),
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
]),
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
.unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
)
.unwrap();
assert!(matches!(
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
std::os::unix::fs::symlink(&destination, &link).unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
)
.unwrap();
let loaded = load_project(
@@ -6,7 +6,7 @@ fn source() -> CompiledSourceProject {
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
]),
}, ProjectLimits::default()).unwrap()
+25 -25
View File
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
#[test]
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
let input = bundle(
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -95,7 +95,7 @@ diagram review "Review" {
#[test]
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
let mut input = bundle(
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
fn typed() -> SourceBundle {
bundle(
"arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
&[
(
"exchanges.arcadia",
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
#[test]
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
#[test]
fn source_errors_keep_exact_fragment_unicode_positions() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
&[(
"child.arcadia",
"// Пример\nsa_function child \"航海\" { parent @missing; }",
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
),
] {
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[("a.arcadia", left), ("b.arcadia", right)],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
assert_eq!(error.related[0].path, "a.arcadia");
}
let input = bundle(
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
#[test]
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
let mut input = bundle(
"arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
&[
("sub/a.arcadia", "include \"../shared.arcadia\";"),
("sub/b.arcadia", "include \"../shared.arcadia\";"),
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
);
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
let input = bundle(
"arcadia 4; project p \"P\" {}",
"arcadia 5; project p \"P\" {}",
&[("A.arcadia", ""), ("a.arcadia", "")],
);
assert_eq!(
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
#[test]
fn project_admission_is_aggregate_and_precedes_global_resolution() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
("b.arcadia", "oa_activity two \"Two\" {}"),
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
#[test]
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
assert!(
language::parse(source).unwrap_err()[0]
.message
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
#[test]
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
let input = bundle(
"arcadia 4;project p\"P\"{include \"child.arcadia\";}",
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
&[(
"child.arcadia",
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
#[test]
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
&[("a.arcadia", "")],
);
assert_eq!(
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
"project_limit"
);
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
(
"a.arcadia",
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
&source[span.start..span.end],
"include \"../shared.arcadia\";"
);
let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
assert_eq!(
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
.target,
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
#[test]
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
let mut project = current.decoded.project.clone();
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
#[test]
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
#[test]
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
#[test]
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
&[(
"item.arcadia",
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
#[test]
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
&[(
"event.arcadia",
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
#[test]
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
&[(
"ports.arcadia",
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
@@ -1017,7 +1017,7 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
#[test]
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
@@ -1052,7 +1052,7 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
#[test]
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
@@ -1104,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
}; }; }
"#;
let current = compile(&bundle(
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
&[("conversation.arcadia", source)],
));
let mut project = current.decoded.project.clone();
@@ -1153,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
#[test]
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
+1
View File
@@ -42,6 +42,7 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
};
p.traces.insert(id, t);
+46
View File
@@ -317,6 +317,7 @@ pub enum Message {
SelectProject,
Search(String),
StartTransition,
StartSemanticReview(Id),
Workflow(crate::workflow::Event),
CollapseExplorer(Id),
ExplorerGroup(crate::explorer::Group),
@@ -1672,12 +1673,57 @@ impl App {
}
}
}
Message::StartSemanticReview(selected) => {
if self.apply() {
match crate::semantic_review::State::new(self.project(), selected) {
Ok(state) => {
self.workflow =
Some(crate::workflow::Workflow::Semantic(Box::new(state)))
}
Err(error) => self.status = error,
}
}
}
Message::Workflow(event) => {
if matches!(event, crate::workflow::Event::Cancel) {
self.workflow = None;
return Task::none();
}
if let Some(mut workflow) = self.workflow.take() {
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
match event {
crate::workflow::Event::Semantic(event) => {
state.update(self.project(), event)
}
crate::workflow::Event::Apply => {
let focus = state.target(self.project());
match state.command(self.project()) {
Ok(command) => {
if self.execute(command) {
if let Some(target) = focus {
self.selected = target;
if let Some(layer) = self.project().layer_of(target)
{
self.layer = layer;
}
self.refresh_draft();
}
self.status = "Semantic review applied · model, source and baseline form one undoable change".into();
return Task::none();
}
state.error = Some(self.status.clone());
}
Err(error) => {
self.status = error.clone();
state.error = Some(error);
}
}
}
_ => {}
}
self.workflow = Some(workflow);
return Task::none();
}
let stay_in_catalog = self.page == Page::Catalog
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
let transition_focus = match &workflow {
+30
View File
@@ -140,8 +140,22 @@ impl Draft {
}
}
pub fn materialize(&self) -> Result<Value, String> {
self.materialize_inputs(|_| true)
}
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
self.materialize_inputs(|path| {
scopes
.iter()
.any(|root| path == root || path.starts_with(&format!("{root}/")))
})
}
fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result<Value, String> {
let mut value = self.value.clone();
for (path, input) in &self.inputs {
if !include(path) {
continue;
}
let Some(target) = value.pointer_mut(path) else {
continue;
};
@@ -1385,6 +1399,22 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
content.into()
}
/// Render one closed property subtree for a workflow-owned typed draft.
/// The caller routes messages to that draft and authorizes the selected scope.
pub fn scoped<'a>(
draft: &'a Draft,
project: &'a Project,
path: &str,
label: &str,
) -> View<'a, Message> {
match draft.value.pointer(path) {
Some(value) => property(draft, project, value, path, label, false),
None => text("This property is not applicable to the current target.")
.size(12)
.into(),
}
}
fn property<'a>(
draft: &'a Draft,
project: &'a Project,
+1
View File
@@ -19,6 +19,7 @@ mod port_edit;
mod port_preview;
mod port_workflow;
mod route_edit;
mod semantic_review;
mod source_navigation;
mod source_tree;
mod source_workspace;
+6 -1
View File
@@ -415,10 +415,15 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
if p.layer_of(id).is_some() {
entries.push(item("Create relationship…", Message::AddRelation, true));
entries.push(item(
"Create / update counterpart…",
"Create counterpart / review prose…",
Message::StartTransition,
true,
));
entries.push(item(
"Review semantics…",
Message::StartSemanticReview(id),
p.traces.values().any(|t| t.oa == id || t.sa == id),
));
}
entries.push(item(
"Attach artifact reference…",
+547
View File
@@ -0,0 +1,547 @@
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
use crate::{
app::{Choice, Message},
forms::Draft,
};
use iced::widget::text_editor;
use oa_core::{transitions::*, *};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Facts,
References,
Impacts,
}
#[derive(Debug, Clone)]
pub enum Event {
Trace(Choice),
Direction(TransitionDirection),
Select(FacetSelector),
Decide(FacetSelector, Option<SemanticDecision>),
Input(String, String),
Text(String, text_editor::Action),
Shape(String, Value),
Variant(String, Value),
Array(String, Option<usize>, i32),
Append(String, Value),
ValuePath(String, crate::data_values::FieldPathKey),
Tab(Tab),
ShowUnchanged(bool),
ReferenceOa(Choice),
ReferenceSa(Choice),
ReferenceSearch(String),
AddReference,
RemoveReference(Id),
AcceptImpact(usize, bool),
Preview,
}
pub struct State {
pub anchor: Id,
pub trace: Option<Id>,
pub direction: TransitionDirection,
pub facts: Vec<FacetDrift>,
pub selected: Option<FacetSelector>,
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub draft: Option<Draft>,
pub plan: Option<SemanticTransitionPlan>,
pub proposed: Option<Project>,
pub error: Option<String>,
pub tab: Tab,
pub show_unchanged: bool,
pub reference_oa: Option<Choice>,
pub reference_sa: Option<Choice>,
pub reference_search: String,
accepted_impacts: Vec<SemanticImpact>,
}
impl State {
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
let explicit = project.traces.get(&selected);
let anchor = explicit.map_or(selected, |trace| trace.oa);
let layer = project
.layer_of(anchor)
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
let choices: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == anchor || t.sa == anchor)
.collect();
if choices.is_empty() {
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
}
let trace = explicit
.map(|t| t.id)
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
let mut state = Self {
anchor,
trace,
direction: if layer == Layer::Oa {
TransitionDirection::OaToSa
} else {
TransitionDirection::SaToOa
},
facts: vec![],
selected: None,
decisions: BTreeMap::new(),
references: BTreeMap::new(),
draft: None,
plan: None,
proposed: None,
error: None,
tab: Tab::Facts,
show_unchanged: false,
reference_oa: None,
reference_sa: None,
reference_search: String::new(),
accepted_impacts: vec![],
};
state.load_trace(project);
Ok(state)
}
pub fn target(&self, project: &Project) -> Option<Id> {
let trace = project.traces.get(&self.trace?)?;
Some(if self.direction == TransitionDirection::OaToSa {
trace.sa
} else {
trace.oa
})
}
fn load_trace(&mut self, project: &Project) {
self.facts.clear();
self.decisions.clear();
self.selected = None;
self.accepted_impacts.clear();
self.references.clear();
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
self.refresh(project);
}
pub fn update(&mut self, project: &Project, event: Event) {
let mut replan = true;
let result = match event {
Event::Trace(choice) => {
if !project
.traces
.get(&choice.id)
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
{
Err("Choose one of this object's existing traces.".into())
} else {
self.trace = Some(choice.id);
self.load_trace(project);
return;
}
}
Event::Direction(direction) => {
self.direction = direction;
self.load_trace(project);
return;
}
Event::Select(facet) => {
self.selected = Some(facet);
replan = false;
Ok(())
}
Event::Decide(facet, decision) => {
if let Some(decision) = decision {
if !self.editable(project, &facet)
&& !(decision == SemanticDecision::KeepTarget
&& self.can_acknowledge_constructor(project, &facet))
{
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
} else {
self.decisions.insert(facet, decision);
Ok(())
}
} else {
self.decisions.remove(&facet);
Ok(())
}
}
Event::Input(path, value) => self.edit(project, &path, |d| {
d.input(&path, value);
Ok(())
}),
Event::Text(path, action) => self.edit(project, &path, |d| {
d.edit_text(&path, action);
Ok(())
}),
Event::Shape(path, value) => self.edit(project, &path, |d| d.shape(&path, value)),
Event::Variant(path, value) => self.edit(project, &path, |d| {
if d.materialize()?
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape(&path, value)
}
}),
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array(&path, index, direction))
}
Event::Append(path, value) => self.edit(project, &path, |d| d.append(&path, value)),
Event::ValuePath(path, key) => self.edit(project, &path, |d| d.value_path(&path, key)),
Event::Tab(tab) => {
self.tab = tab;
replan = false;
Ok(())
}
Event::ShowUnchanged(show) => {
self.show_unchanged = show;
replan = false;
Ok(())
}
Event::ReferenceOa(choice) => {
self.reference_oa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSa(choice) => {
self.reference_sa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSearch(value) => {
self.reference_search = value;
replan = false;
Ok(())
}
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
(Some(oa), Some(sa)) => {
self.references.insert(oa.id, sa.id);
Ok(())
}
_ => Err("Choose both an Operational and a System reference.".into()),
},
Event::RemoveReference(id) => {
self.references.remove(&id);
Ok(())
}
Event::AcceptImpact(index, accepted) => {
if let Some(impact) = self
.plan
.as_ref()
.and_then(|p| p.impacts.get(index))
.cloned()
{
self.accepted_impacts.retain(|i| i.object != impact.object);
if accepted {
self.accepted_impacts.push(impact);
}
}
self.refresh(project);
return;
}
Event::Preview => Ok(()),
};
if let Err(error) = result {
self.error = Some(error);
self.plan = None;
self.proposed = None;
return;
}
if replan {
self.accepted_impacts.clear();
self.refresh(project);
}
}
fn edit(
&mut self,
project: &Project,
path: &str,
action: impl FnOnce(&mut Draft) -> Result<(), String>,
) -> Result<(), String> {
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
return Err("Choose Set final value before editing this property.".into());
}
let root = self
.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.ok_or("Selected scalar property is unavailable.")?;
if path != root && !path.starts_with(&format!("{root}/")) {
return Err("Only the selected scalar property can change in this draft.".into());
}
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
// Protected identity and top-level body/perspective controls are never exposed.
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
return Err("Semantic candidates retain identity, alias and perspective.".into());
}
action(draft)
}
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
self.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
&& !scalar::grouped(facet)
}
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
if !scalar::grouped(facet) {
return false;
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
return false;
};
if trace
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
{
return false;
}
self.facts
.iter()
.find(|f| f.selector == *facet)
.is_some_and(|f| {
scalar::value(&f.current, Layer::Oa).is_some()
&& scalar::observation(&f.current, Layer::Oa)
== scalar::observation(&f.current, Layer::Sa)
})
}
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose the trace whose review history you want to update.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut transition = TransitionRequest::new(self.direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.traces.insert(source, trace.id);
request.references = self.references.clone();
request.reviews.push(SemanticReviewRequest {
source,
resolutions: self
.decisions
.iter()
.map(|(facet, decision)| FacetResolution {
facet: facet.clone(),
decision: *decision,
})
.collect(),
reset: vec![],
});
request.accepted_impacts = self.accepted_impacts.clone();
if self
.decisions
.values()
.any(|d| *d == SemanticDecision::Candidate)
{
let original =
Draft::from_project(project, target).ok_or("Target has no editable record.")?;
let mut final_value = original.value;
let scopes: Vec<_> = self
.decisions
.iter()
.filter(|(_, decision)| **decision == SemanticDecision::Candidate)
.filter_map(|(facet, _)| scalar::binding(project, target, facet))
.collect();
let draft = self
.draft
.as_ref()
.ok_or("Target draft is unavailable.")?
.materialize_scopes(&scopes)?;
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (from, to) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(from, to).is_some_and(|old| old != to) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
map.insert(source, target);
for (facet, decision) in &self.decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => draft
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?,
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
}
};
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
let candidate = if project.elements.contains_key(&target) {
SemanticRecord::Element(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
} else {
SemanticRecord::Relation(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
};
request.candidates.push(candidate);
}
Ok(request)
}
pub fn refresh(&mut self, project: &Project) {
self.plan = None;
self.proposed = None;
self.error = None;
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
self.error = Some(
"Several traces are available. Choose the correspondence and review history above."
.into(),
);
return;
};
match semantic_facets(project, trace, &self.references) {
Ok(facts) => self.facts = facts,
Err(error) => {
self.error = Some(error);
return;
}
}
if self
.selected
.as_ref()
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
{
self.selected = self
.facts
.iter()
.find(|f| f.drift != Drift::Unchanged)
.or(self.facts.first())
.map(|f| f.selector.clone());
}
let request = match self.request(project) {
Ok(request) => request,
Err(error) => {
self.error = Some(error);
return;
}
};
match plan_semantic_transition(project, request) {
Ok(plan) => {
if plan.conflicts.is_empty() {
match prepare_semantic_transition(project, &plan) {
Ok(prepared) => self.proposed = Some(prepared.project),
Err(error) => self.error = Some(error.to_string()),
}
}
self.plan = Some(plan);
}
Err(error) => self.error = Some(error.to_string()),
}
}
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
if self.decisions.is_empty() {
return Err("Choose at least one property to review.".into());
}
if let Some(error) = &self.error {
return Err(error.clone());
}
let plan = self
.plan
.as_ref()
.ok_or("Refresh the review preview before applying.")?;
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
Ok(Command::Batch(plan.commands.clone()))
}
pub fn can_apply(&self) -> bool {
!self.decisions.is_empty()
&& self.error.is_none()
&& self
.plan
.as_ref()
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
}
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
let historical: BTreeSet<_> = self
.facts
.iter()
.flat_map(|f| scalar::references(&f.current))
.chain(
self.trace
.and_then(|id| project.traces.get(&id))
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.into_iter()
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
)
.collect();
let mut ids: BTreeSet<_> = project
.elements
.keys()
.chain(project.relations.keys())
.chain(project.artifact_links.keys())
.filter(|id| project.layer_of(**id) == Some(layer))
.copied()
.collect();
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
let search = self.reference_search.to_lowercase();
let mut choices: Vec<_> = ids
.into_iter()
.map(|id| view::choice(project, id))
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
.collect();
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
}
pub fn form_event(message: Message) -> Message {
let event = match message {
Message::Field(path, value) => Event::Input(path, value),
Message::FieldText(path, action) => Event::Text(path, action),
Message::FieldShape(path, value) => Event::Shape(path, value),
Message::FieldVariant(path, value) => Event::Variant(path, value),
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
Message::FieldAppend(path, value) => Event::Append(path, value),
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
_ => Event::Preview,
};
Message::Workflow(crate::workflow::Event::Semantic(event))
}
@@ -0,0 +1,288 @@
//! Fixed typed scalar bindings for property forms. Core validates every result.
use oa_core::*;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
struct Own {
whole: Value,
prefix: Vec<String>,
kind: String,
}
fn enum_tag(value: &Value) -> Option<&str> {
value.as_str().or_else(|| {
value
.as_object()
.and_then(|v| v.keys().next().map(String::as_str))
})
}
impl Own {
fn get(project: &Project, id: Id) -> Option<Self> {
let (whole, element) = if let Some(e) = project.elements.get(&id) {
(serde_json::to_value(e).ok()?, true)
} else {
(
serde_json::to_value(project.relations.get(&id)?).ok()?,
false,
)
};
let mut prefix = vec!["body".to_string()];
let mut value = &whole["body"];
if element {
let layer = enum_tag(value)?.to_string();
prefix.push(layer.clone());
value = &value[&layer];
if enum_tag(value) == Some("Shared") {
prefix.push("Shared".into());
value = &value["Shared"];
}
}
let kind = enum_tag(value)?.to_string();
prefix.push(kind.clone());
Some(Self {
whole,
prefix,
kind,
})
}
}
fn at<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> {
for part in path {
value = value.get(part)?;
}
Some(value)
}
fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
use FacetSelector as F;
let (family, parts): (&str, &[&str]) = match facet {
F::Header(HeaderFacet::Name) => return Some(vec!["name".into()]),
F::Header(HeaderFacet::Description) => return Some(vec!["description".into()]),
F::Header(HeaderFacet::Parent) => {
return own.whole.get("parent").map(|_| vec!["parent".into()]);
}
F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]),
F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]),
F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]),
F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]),
F::Data(DataFacet::Constructor) => ("DataType", &["definition"]),
F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]),
F::Data(DataFacet::IntegerMaximum) => ("DataType", &["definition", "Integer", "maximum"]),
F::Data(DataFacet::RealMinimum) => ("DataType", &["definition", "Real", "minimum"]),
F::Data(DataFacet::RealMaximum) => ("DataType", &["definition", "Real", "maximum"]),
F::Data(DataFacet::StringMinimumLength) => {
("DataType", &["definition", "String", "minimum_length"])
}
F::Data(DataFacet::StringMaximumLength) => {
("DataType", &["definition", "String", "maximum_length"])
}
F::Data(DataFacet::RecordSupertype) => ("DataType", &["definition", "Record", "supertype"]),
F::Data(DataFacet::Default) => ("DataType", &["default"]),
F::Data(DataFacet::Unit) => ("DataType", &["unit"]),
F::Field(FieldFacet::Type) => ("DataField", &["type_ref"]),
F::Field(FieldFacet::Minimum) => ("DataField", &["minimum"]),
F::Field(FieldFacet::Maximum) => ("DataField", &["maximum"]),
F::Field(FieldFacet::Unit) => ("DataField", &["unit"]),
F::Field(FieldFacet::Direction) => ("DataField", &["direction"]),
F::Field(FieldFacet::Storage) => ("DataField", &["storage"]),
F::Field(FieldFacet::Default) => ("DataField", &["default"]),
F::Port(PortFacet::Constructor) => ("Port", &["contract"]),
F::Port(PortFacet::ComponentKind) => ("Port", &["contract", "Component", "kind"]),
F::Port(PortFacet::Orientation) => ("Port", &["contract", "Component", "orientation"]),
F::Port(PortFacet::LegacyDirection) => ("Port", &["legacy", "direction"]),
F::Port(PortFacet::LegacyInterface) => ("Port", &["legacy", "interface"]),
F::Scenario(ScenarioFacet::Viewpoint) => ("Scenario", &["viewpoint"]),
F::Scenario(ScenarioFacet::Context) => ("Scenario", &["context"]),
F::Scenario(ScenarioFacet::Precondition) => ("Scenario", &["precondition"]),
F::Scenario(ScenarioFacet::Postcondition) => ("Scenario", &["postcondition"]),
F::Lifeline(LifelineFacet::Represented) => ("Lifeline", &["represented"]),
F::Occurrence(OccurrenceFacet::Constructor) => ("Occurrence", &["body"]),
F::Occurrence(OccurrenceFacet::Precondition) => ("Occurrence", &["precondition"]),
F::Occurrence(OccurrenceFacet::Postcondition) => ("Occurrence", &["postcondition"]),
F::Occurrence(OccurrenceFacet::Exchange) => {
("Occurrence", &["body", "Exchange", "exchange"])
}
F::Occurrence(OccurrenceFacet::Sender) => ("Occurrence", &["body", "Exchange", "sender"]),
F::Occurrence(OccurrenceFacet::Receiver) => {
("Occurrence", &["body", "Exchange", "receiver"])
}
F::Occurrence(OccurrenceFacet::MessageKind) => {
("Occurrence", &["body", "Exchange", "message_kind"])
}
F::Occurrence(OccurrenceFacet::TimerExpression) => (
"Occurrence",
&["body", "Exchange", "message_kind", "Timer", "expression"],
),
F::Occurrence(OccurrenceFacet::ReplyTo) => {
("Occurrence", &["body", "Exchange", "reply_to"])
}
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
("Occurrence", &["body", "Action", "lifeline"])
}
F::Occurrence(OccurrenceFacet::ControlKind) => ("Occurrence", &["body", "Control", "kind"]),
F::Occurrence(OccurrenceFacet::Sequence) => {
("Occurrence", &["body", "SequenceReference", "sequence"])
}
F::Occurrence(OccurrenceFacet::Scenario) => {
("Occurrence", &["body", "ScenarioUse", "scenario"])
}
F::Occurrence(OccurrenceFacet::State) => ("Occurrence", &["body", "State", "state"]),
F::Occurrence(OccurrenceFacet::StateLifeline) => {
("Occurrence", &["body", "State", "lifeline"])
}
F::Occurrence(OccurrenceFacet::FragmentOperator) => {
("Occurrence", &["body", "Fragment", "operator"])
}
F::Occurrence(OccurrenceFacet::BranchGuard) => ("Occurrence", &["body", "Branch", "guard"]),
F::Event(EventFacet::Constructor) => ("Event", &["body"]),
F::Event(EventFacet::TimeKind) => ("Event", &["body", "Time", "kind"]),
F::Event(EventFacet::TimeExpression) => ("Event", &["body", "Time", "expression"]),
F::Event(EventFacet::ChangeExpression) => ("Event", &["body", "Change", "expression"]),
F::State(StateFacet::Kind) => ("State", &["kind"]),
F::State(StateFacet::Entry) => ("State", &["entry"]),
F::State(StateFacet::Exit) => ("State", &["exit"]),
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
F::Relation(RelationFacet::Source) => ("*", &["source"]),
F::Relation(RelationFacet::Target) => ("*", &["target"]),
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
F::Relation(RelationFacet::SourceRole) => ("DataAssociation", &["source_role"]),
F::Relation(RelationFacet::TargetRole) => ("DataAssociation", &["target_role"]),
F::Relation(RelationFacet::SourceMinimum) => ("DataAssociation", &["source_minimum"]),
F::Relation(RelationFacet::SourceMaximum) => ("DataAssociation", &["source_maximum"]),
F::Relation(RelationFacet::TargetMinimum) => ("DataAssociation", &["target_minimum"]),
F::Relation(RelationFacet::TargetMaximum) => ("DataAssociation", &["target_maximum"]),
F::Relation(RelationFacet::SourceNavigable) => ("DataAssociation", &["source_navigable"]),
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
F::Relation(RelationFacet::Trigger) => ("StateTransition", &["trigger"]),
F::Relation(RelationFacet::Guard) => ("StateTransition", &["guard"]),
F::Relation(RelationFacet::Effect) => ("StateTransition", &["effect"]),
F::Relation(RelationFacet::StateActionPhase) => ("StateAction", &["phase"]),
_ => return None,
};
if family != "*" && own.kind != family {
return None;
}
let mut path = own.prefix.clone();
path.extend(parts.iter().map(|s| s.to_string()));
at(&own.whole, &path).map(|_| path)
}
pub(super) fn binding(project: &Project, id: Id, facet: &FacetSelector) -> Option<String> {
let own = Own::get(project, id)?;
path(&own, facet).map(|parts| format!("/{}", parts.join("/")))
}
pub(super) fn grouped(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Data(DataFacet::Constructor)
| FacetSelector::Port(PortFacet::Constructor)
| FacetSelector::Occurrence(
OccurrenceFacet::Constructor | OccurrenceFacet::MessageKind
)
| FacetSelector::Event(EventFacet::Constructor)
)
}
fn pair_json(fact: &FacetReview) -> Value {
let json = serde_json::to_value(fact).expect("Typed semantic fact");
let (family, value) = json
.as_object()
.expect("Typed family")
.iter()
.next()
.expect("Family");
match family.as_str() {
"Member" | "Order" | "Literal" | "Annotation" | "Binding" | "Child" | "Incident" => {
value["review"].clone()
}
"Artifact" => value["review"]
.as_object()
.expect("Artifact leaf")
.values()
.next()
.expect("Artifact value")
.clone(),
_ => value
.as_object()
.expect("Scalar leaf")
.values()
.next()
.expect("Scalar value")
.clone(),
}
}
pub(super) fn observation(fact: &FacetReview, layer: Layer) -> Value {
pair_json(fact)[match layer {
Layer::Oa => "oa",
Layer::Sa => "sa",
}]
.clone()
}
pub(super) fn value(fact: &FacetReview, layer: Layer) -> Option<Value> {
observation(fact, layer).get("Value").cloned()
}
pub(super) fn source_value(
fact: &FacetReview,
source: Layer,
correspondence: &BTreeMap<Id, Id>,
) -> Result<Value, String> {
// Retain only the source observation before invoking the core's typed UUID
// visitor. UUID-shaped prose must never be treated as a reference.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let leaf = json
.as_object_mut()
.ok_or("Missing scalar family")?
.values_mut()
.next()
.ok_or("Missing scalar family")?
.as_object_mut()
.ok_or("Missing scalar leaf")?
.values_mut()
.next()
.ok_or("Missing scalar leaf")?;
let selected = observation(fact, source);
leaf["oa"] = selected.clone();
leaf["sa"] = selected;
let typed = serde_json::from_value(json).map_err(|e| e.to_string())?;
let mut baseline = SemanticBaseline {
facets: vec![typed],
};
let mut missing = BTreeSet::new();
visit_semantic_references(&mut baseline, |id| {
if let Some(mapped) = correspondence.get(id) {
*id = *mapped;
} else {
missing.insert(*id);
}
});
if !missing.is_empty() {
return Err(format!(
"Choose reference counterparts before taking this value: {}",
missing
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
value(&baseline.facets[0], source)
.ok_or("Source property is not applicable; review its constructor first.".into())
}
pub(super) fn references(fact: &FacetReview) -> BTreeSet<Id> {
let mut ids = BTreeSet::new();
visit_semantic_references(
&mut SemanticBaseline {
facets: vec![fact.clone()],
},
|id| {
ids.insert(*id);
},
);
ids
}
@@ -0,0 +1,784 @@
use super::*;
use crate::{app::App, document::Document, workflow::Workflow};
use serde_json::json;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn apply(app: &mut App) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
}
fn state(app: &App) -> &State {
let Some(Workflow::Semantic(state)) = &app.workflow else {
panic!("Semantic workflow missing: {}", app.status)
};
state
}
fn fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational range", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System range", None)
.unwrap();
for (id, min, max, unit) in [(oa, 0, 20, "m"), (sa, 1, 30, "cm")] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(min),
maximum: Some(max),
},
default: None,
unit: unit.into(),
};
}
let trace = add_trace(&mut p, oa, sa, "Range rationale");
(p, oa, sa, trace)
}
fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: name.into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
id
}
fn start(app: &mut App, source: Id) {
let _ = app.update(Message::StartSemanticReview(source));
}
fn facet(value: DataFacet) -> FacetSelector {
FacetSelector::Data(value)
}
fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
send(app, Event::Select(selected.clone()));
send(app, Event::Decide(selected, Some(decision)));
}
#[test]
fn scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, trace) = fixture();
let mut app = App::with_project(p);
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Retain this engineering rationale.\r\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
let (source, target, prefix, expected_max, kept_min) = if reverse {
(sa, oa, "/body/Oa/Shared/DataType", 30, 0)
} else {
(oa, sa, "/body/Sa/Shared/DataType", 20, 1)
};
start(&mut app, source);
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::IntegerMaximum),
SemanticDecision::TakeSource,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()),
);
let prepared =
prepare_semantic_transition(app.project(), state(&app).plan.as_ref().unwrap())
.unwrap()
.project;
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
app.project(),
&before.decoded.project,
"Preview must not mutate accepted state"
);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(
app.project(),
&prepared,
"Apply must equal the exact reviewed project including diagrams"
);
assert_eq!(app.document.revision(), revision + 1);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, maximum },
unit,
..
}) = app.project().elements[&target].shared()
else {
panic!()
};
assert_eq!((*minimum, *maximum), (Some(kept_min), Some(expected_max)));
assert_eq!(unit, "kg\n exact suffix ");
let baseline = app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
assert_eq!(baseline.facets.len(), 3, "Unselected facts stay unreviewed");
assert!(
app.document.bundle().files[&app.document.bundle().entry]
.contains("// Retain this engineering rationale.")
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
}
}
#[test]
fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
send(&mut app, Event::Input(path.into(), "-".into()));
assert!(!state(&app).can_apply());
send(&mut app, Event::Tab(Tab::References));
assert!(
state(&app).error.is_some(),
"Navigation must retain visible validation errors"
);
apply(&mut app);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
assert_eq!(app.document.bundle(), &before.bundle);
send(&mut app, Event::Input(path.into(), "2".into()));
send(&mut app, Event::Input("/alias".into(), "unexpected".into()));
assert!(!state(&app).can_apply());
assert!(
state(&app)
.error
.as_ref()
.unwrap()
.contains("selected scalar")
);
assert_eq!(app.project(), &before.decoded.project);
send(&mut app, Event::Preview);
assert!(state(&app).can_apply());
}
#[test]
fn explicit_trace_selection_updates_only_that_history() {
let (mut p, oa, sa, first) = fixture();
let second = add_trace(&mut p, oa, sa, "Independent assessment");
p.traces.get_mut(&second).unwrap().kind = TraceKind::Item;
let mut app = App::with_project(p);
start(&mut app, oa);
assert!(state(&app).trace.is_none());
assert!(!state(&app).can_apply());
send(
&mut app,
Event::Trace(Choice {
id: second,
label: "Independent assessment".into(),
}),
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert!(
app.project().traces[&first]
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_none()
);
assert!(
app.project().traces[&second]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
}
#[test]
fn stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history() {
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
app.document
.execute(Command::Rename(oa, "Changed outside preview".into()))
.unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
apply(&mut app);
assert!(state(&app).error.as_ref().unwrap().contains("stale"));
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
let bundle = app.document.bundle().clone();
let mut limits = source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
app.document = Document::from_bundle(bundle, limits).unwrap();
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
"long final unit ".repeat(40),
),
);
assert!(state(&app).can_apply());
apply(&mut app);
assert!(state(&app).error.is_some());
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo());
}
#[test]
fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessing() {
let (mut p, oa, sa, _) = fixture();
let parent_oa = p
.add_element(Kind::DataType, Layer::Oa, "OA parent", None)
.unwrap();
let parent_sa = p
.add_element(Kind::DataType, Layer::Sa, "SA parent", None)
.unwrap();
for id in [parent_oa, parent_sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
// ExchangeItem supertypes are an independent optional typed reference.
for (id, parent) in [(oa, Some(parent_oa)), (sa, None)] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: parent },
default: None,
unit: String::new(),
};
}
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
send(
&mut app,
Event::ReferenceOa(Choice {
id: parent_oa,
label: "OA parent".into(),
}),
);
send(
&mut app,
Event::ReferenceSa(Choice {
id: parent_sa,
label: "SA parent".into(),
}),
);
send(&mut app, Event::AddReference);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
parent_oa.to_string(),
),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
unit,
..
}) = app.project().elements[&sa].shared()
else {
panic!()
};
assert_eq!(*supertype, Some(parent_sa));
assert_eq!(
unit,
&parent_oa.to_string(),
"UUID-shaped prose must not be remapped"
);
}
#[test]
fn read_only_structured_facts_and_partial_rollup_are_honest() {
let (p, oa, _, trace) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
send(
&mut app,
Event::Decide(
facet(DataFacet::Constructor),
Some(SemanticDecision::TakeSource),
),
);
assert!(state(&app).decisions.is_empty());
assert!(state(&app).error.is_some());
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
apply(&mut app);
let rollup = trace_semantic_drift(
app.project(),
&app.project().traces[&trace],
&BTreeMap::new(),
)
.unwrap();
assert!(rollup.own_unreviewed);
assert!(summary(app.project(), &app.project().traces[&trace]).contains("Unreviewed"));
let mut request = State::new(app.project(), trace).unwrap();
request.update(
app.project(),
Event::Select(facet(DataFacet::IntegerMaximum)),
);
let _ = request.view(app.project());
assert_eq!(
scalar::value(
&capture_facet(
app.project(),
oa,
request.target(app.project()).unwrap(),
&facet(DataFacet::Default)
)
.unwrap(),
Layer::Oa
),
Some(json!(null))
);
}
#[test]
fn first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves() {
let (p, oa, _, trace) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
assert!(state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor)));
decide(
&mut app,
facet(DataFacet::Constructor),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()),
);
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
apply(&mut app);
let facts = &app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 2);
assert!(
facts
.iter()
.any(|f| f.selector() == facet(DataFacet::Constructor))
);
assert!(
!facts
.iter()
.any(|f| f.selector() == facet(DataFacet::IntegerMinimum))
);
start(&mut app, oa);
assert!(
!state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor))
);
let remaining: Vec<_> = state(&app)
.facts
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.map(|f| f.selector.clone())
.collect();
for selector in remaining {
decide(&mut app, selector, SemanticDecision::KeepTarget);
}
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert!(
!trace_semantic_drift(
app.project(),
&app.project().traces[&trace],
&BTreeMap::new()
)
.unwrap()
.own_unreviewed
);
}
#[test]
fn state_kind_scalar_uses_typed_candidate_and_core_validation() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let machine = p
.add_element(Kind::StateMachine, layer, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, layer, "Region", Some(machine))
.unwrap();
pair.push(
p.add_element(Kind::State, layer, "Ready", Some(region))
.unwrap(),
);
}
add_trace(&mut p, pair[0], pair[1], "State review");
let mut app = App::with_project(p);
start(&mut app, pair[0]);
decide(
&mut app,
FacetSelector::State(StateFacet::Kind),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")),
);
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
let expected = state(&app).proposed.clone().unwrap();
apply(&mut app);
assert_eq!(app.project(), &expected);
assert_eq!(app.project().elements[&pair[1]].kind(), Kind::Final);
}
#[test]
fn relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let a = p
.add_element(Kind::DataType, layer, "Origin type", None)
.unwrap();
let b = p
.add_element(Kind::DataType, layer, "Destination type", None)
.unwrap();
for id in [a, b] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
let relation = p.relate(RelationKind::DataAssociation, "Association", a, b);
if let RelationBody::DataAssociation { source_role, .. } =
&mut p.relations.get_mut(&relation).unwrap().body
{
*source_role = if layer == Layer::Oa {
"Authored source role"
} else {
"Local role"
}
.into();
}
pair.push(relation);
}
add_trace(&mut p, pair[0], pair[1], "Association review");
let original = p.relations[&pair[1]].clone();
let mut app = App::with_project(p);
start(&mut app, pair[0]);
decide(
&mut app,
FacetSelector::Relation(RelationFacet::SourceRole),
SemanticDecision::TakeSource,
);
decide(
&mut app,
FacetSelector::Relation(RelationFacet::TargetMaximum),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape("/body/DataAssociation/target_maximum".into(), json!(4)),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let accepted = &app.project().relations[&pair[1]];
assert_eq!(accepted.alias, original.alias);
assert_eq!(
(accepted.source(), accepted.target()),
(original.source(), original.target())
);
let RelationBody::DataAssociation {
source_role,
target_maximum,
..
} = &accepted.body
else {
panic!()
};
assert_eq!(source_role, "Authored source role");
assert_eq!(*target_maximum, Some(4));
}
#[test]
fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected_scalar() {
let (p, oa, sa, _) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(path.into(), "-".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
"reviewed local".into(),
),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, .. },
unit,
..
}) = app.project().elements[&sa].shared()
else {
panic!()
};
assert_eq!(*minimum, Some(1));
assert_eq!(unit, "reviewed local");
}
#[test]
fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approval() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Inherited record", None)
.unwrap();
for (id, supertype) in [(oa, None), (sa, Some(base)), (base, None)] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Inherited value", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Telemetry", None)
.unwrap();
let root = p
.add_element(Kind::DataField, Layer::Sa, "Position", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (root, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(RelationKind::FunctionalExchange, "Telemetry exchange", a, b);
let (source_port, target_port) = match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} => {
items.push(item);
(*source_port, *target_port)
}
_ => panic!(),
};
for id in [source_port, target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Telemetry scenario", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Send position", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![root],
field: leaf,
expression: "current position".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
add_trace(&mut p, oa, sa, "Record inheritance review");
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
let impacts = &state(&app).plan.as_ref().unwrap().impacts;
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
let exact = impacts[0].clone();
send(&mut app, Event::AcceptImpact(0, true));
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
assert_eq!(
state(&app).plan.as_ref().unwrap().request.accepted_impacts,
vec![exact]
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
assert!(
state(&app)
.plan
.as_ref()
.unwrap()
.request
.accepted_impacts
.is_empty()
);
assert!(!state(&app).can_apply());
send(&mut app, Event::AcceptImpact(0, true));
let expected = state(&app).proposed.clone().unwrap();
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert_eq!(app.project(), &expected);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&message].shared()
else {
panic!()
};
assert!(values.is_empty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
@@ -0,0 +1,580 @@
use super::*;
use crate::{style, views::short};
use iced::{
Element, Fill,
widget::{
Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input,
},
};
fn event(event: Event) -> Message {
Message::Workflow(crate::workflow::Event::Semantic(event))
}
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
button(text(label.into()).size(12))
.padding([7, 9])
.style(style::nav)
.on_press(event(event_value))
}
pub(super) fn choice(project: &Project, id: Id) -> Choice {
let alias = project
.elements
.get(&id)
.map(|v| v.alias.as_str())
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
Choice {
id,
label: alias.map_or_else(
|| format!("Deleted reference · {id}"),
|alias| format!("{} · @{alias}", project.named(id)),
),
}
}
fn words(value: &str) -> String {
let mut out = String::new();
for (index, c) in value.chars().enumerate() {
if c == '_' {
out.push(' ');
} else {
if index > 0 && c.is_uppercase() {
out.push(' ');
}
out.push(c);
}
}
out
}
pub(super) fn group(facet: &FacetSelector) -> &'static str {
match facet {
FacetSelector::Header(_) => "Name and ownership",
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
"Data and defaults"
}
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
"Contracts and interfaces"
}
FacetSelector::Scenario(_)
| FacetSelector::Lifeline(_)
| FacetSelector::Occurrence(_)
| FacetSelector::Event(_)
| FacetSelector::State(_)
| FacetSelector::Annotation(_)
| FacetSelector::Binding(_) => "Behavior and scenarios",
FacetSelector::Relation(_) | FacetSelector::Constraint(_) => {
"Relationships and constraints"
}
FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order",
FacetSelector::Child(_)
| FacetSelector::Incident { .. }
| FacetSelector::Artifact { .. } => "Attached records",
}
}
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
let keys = |keys: &KeyPair<Id>| {
format!(
"{} ↔ {}",
keys.oa.map(|id| project.named(id)).unwrap_or("Absent"),
keys.sa.map(|id| project.named(id)).unwrap_or("Absent")
)
};
match facet {
FacetSelector::Member {
collection,
keys: pair,
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
FacetSelector::Literal(pair) => format!(
"Literal · {} ↔ {}",
pair.oa.as_deref().unwrap_or("Absent"),
pair.sa.as_deref().unwrap_or("Absent")
),
FacetSelector::Annotation(pair) => format!(
"Message value · {} ↔ {}",
pair.oa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent"),
pair.sa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent")
),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
FacetSelector::Artifact { keys: pair, facet } => {
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
}
_ => {
let json = serde_json::to_value(facet).expect("Typed selector");
let value = json
.as_object()
.expect("Family")
.values()
.next()
.expect("Leaf");
words(value.as_str().unwrap_or("Property"))
}
}
}
fn drift(drift: Drift) -> &'static str {
match drift {
Drift::Unreviewed => "Unreviewed",
Drift::Unchanged => "Unchanged since review",
Drift::OaChanged => "Operational changed",
Drift::SaChanged => "System changed",
Drift::BothChanged => "Both changed",
}
}
pub(crate) fn summary(project: &Project, trace: &Trace) -> String {
let references = BTreeMap::from([(trace.oa, trace.sa)]);
match trace_semantic_drift(project, trace, &references) {
Ok(value) => format!(
"Semantics: {}{} · Linked: {}{}",
drift(value.own),
if value.own_unreviewed && value.own != Drift::Unreviewed {
" · incomplete review"
} else {
""
},
drift(value.linked),
if value.linked_unreviewed && value.linked != Drift::Unreviewed {
" · incomplete review"
} else {
""
}
),
Err(error) => format!("Semantic review unavailable: {error}"),
}
}
fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> String {
match value {
Value::Null => "Not set".into(),
Value::Bool(value) => if *value { "Yes" } else { "No" }.into(),
Value::Number(value) => value.to_string(),
Value::String(value) => {
if let Ok(id) = value.parse::<Id>()
&& references.contains(&id)
{
choice(project, id).label
} else if value.is_empty() {
"Empty text".into()
} else {
format!("“{value}”")
}
}
Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(),
Value::Array(values) => values
.iter()
.enumerate()
.map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references)))
.collect::<Vec<_>>()
.join("\n"),
Value::Object(values) => values
.iter()
.map(|(key, value)| {
format!("{}: {}", words(key), formatted(project, value, references))
})
.collect::<Vec<_>>()
.join("\n"),
}
}
fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
let Some(fact) = fact else {
return "Unreviewed — no previous observation".into();
};
let value = scalar::observation(fact, layer);
if value.as_str() == Some("NotApplicable") {
"Not applicable".into()
} else {
formatted(project, &value["Value"], &scalar::references(fact))
}
}
impl State {
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let trace = self.trace.and_then(|id| project.traces.get(&id));
let mut traces: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
.map(|t| Choice {
id: t.id,
label: format!(
"{} · {:?} · {} ↔ {} · @{}",
t.name,
t.kind,
project.named(t.oa),
project.named(t.sa),
t.alias
),
})
.collect();
traces.sort_by(|a, b| a.label.cmp(&b.label));
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
let direction = if self.direction == TransitionDirection::OaToSa {
"Use Operational changes in System"
} else {
"Use System changes in Operational"
};
let mut content = column![
text("Review semantics").size(22),
row![
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
].spacing(8),
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6),
].spacing(8);
let body = match self.tab {
Tab::Facts => self.facts_view(project),
Tab::References => self.references_view(project),
Tab::Impacts => self.impacts_view(project),
};
content = content.push(body);
if let Some(error) = &self.error {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
if let Some(plan) = &self.plan {
for error in &plan.conflicts {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
let unreviewed = self
.facts
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.count();
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED));
if let Some(trace) = trace {
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
}
content
.push(
row![
text(
"Apply updates the selected history and model together; Undo restores both."
)
.size(10)
.width(Fill),
button(text("Cancel").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(crate::workflow::Event::Cancel)),
button(text("Apply reviewed changes").size(12))
.padding(8)
.on_press_maybe(
self.can_apply()
.then_some(Message::Workflow(crate::workflow::Event::Apply))
),
]
.spacing(8)
.align_y(iced::Center),
)
.into()
}
fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut list = column![
checkbox(self.show_unchanged)
.label("Include unchanged facts")
.text_size(11)
.on_toggle(|v| event(Event::ShowUnchanged(v)))
]
.spacing(5);
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
for fact in &self.facts {
if self.show_unchanged
|| fact.drift != Drift::Unchanged
|| self.decisions.contains_key(&fact.selector)
|| self.selected.as_ref() == Some(&fact.selector)
{
groups.entry(group(&fact.selector)).or_default().push(fact);
}
}
for (group, facts) in groups {
list = list.push(text(group).size(12).color(style::TEAL));
for fact in facts {
let mark = match self.decisions.get(&fact.selector) {
Some(SemanticDecision::KeepTarget) => "Keep · ",
Some(SemanticDecision::TakeSource) => "Take · ",
Some(SemanticDecision::Candidate) => "Final value · ",
None => "",
};
list = list.push(
button(
column![
text(short(&title(project, &fact.selector), 52)).size(12),
text(format!("{mark}{}", drift(fact.drift))).size(10)
]
.spacing(3),
)
.padding(7)
.width(Fill)
.style(if self.selected.as_ref() == Some(&fact.selector) {
style::selected
} else {
style::nav
})
.on_press(event(Event::Select(fact.selector.clone()))),
);
}
}
row![
scrollable(list).height(305).width(260),
scrollable(self.detail(project)).height(305).width(Fill)
]
.spacing(12)
.into()
}
fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let Some(fact) = self
.selected
.as_ref()
.and_then(|s| self.facts.iter().find(|f| &f.selector == s))
else {
return text("Choose a trace and property to compare.")
.size(13)
.into();
};
let trace = self.trace.and_then(|id| project.traces.get(&id));
let previous = trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
let side = |layer, label: &str| {
column![
text(format!(
"{label}{}",
if layer == self.direction.target_layer() {
" · destination"
} else {
" · source"
}
))
.size(12)
.color(style::TEAL),
text("Previously reviewed").size(10).color(style::MUTED),
text(observed(project, previous, layer)).size(12),
text("Current").size(10).color(style::MUTED),
text(observed(project, Some(&fact.current), layer)).size(12),
]
.spacing(5)
.width(Fill)
};
let mut detail = column![
text(title(project, &fact.selector)).size(17),
text(drift(fact.drift)).size(11),
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
]
.spacing(10);
if let Some(proposed) = &self.proposed
&& let Some(trace) = trace
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
{
detail = detail
.push(
text("Proposed destination value · validated preview")
.size(11)
.color(style::TEAL),
)
.push(
text(observed(
proposed,
Some(&after),
self.direction.target_layer(),
))
.size(12),
);
} else {
detail = detail.push(
text("No validated final preview. Resolve the messages below before applying.")
.size(11)
.color(style::MUTED),
);
}
if self.editable(project, &fact.selector) {
let destination = if self.direction.target_layer() == Layer::Sa {
"System"
} else {
"Operational"
};
let source = if self.direction.source_layer() == Layer::Oa {
"Operational"
} else {
"System"
};
let mut buttons = row![
action(
format!("Keep {destination}"),
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
),
action(
format!("Take {source}"),
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
)
]
.spacing(5);
buttons = buttons.push(action(
"Set final value…",
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
));
detail = detail.push(buttons).push(action(
"Leave unselected",
Event::Decide(fact.selector.clone(), None),
));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = &self.draft
&& let Some(path) = self
.target(project)
.and_then(|id| scalar::binding(project, id, &fact.selector))
{
detail = detail.push(text("Final destination value").size(12)).push(
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
.map(form_event),
);
}
} else if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
} else {
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
}
if matches!(
fact.selector,
FacetSelector::Child(_)
| FacetSelector::Incident { .. }
| FacetSelector::Artifact { .. }
) {
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
}
detail.into()
}
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![
text("Reference correspondence · always Operational → System").size(15),
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12),
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
row![
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
action("Use pair", Event::AddReference),
].spacing(7),
].spacing(9);
for (oa, sa) in &self.references {
content = content.push(
row![
text(format!(
"{}\n↔ {}",
choice(project, *oa).label,
choice(project, *sa).label
))
.size(12)
.width(Fill),
action("Remove pair", Event::RemoveReference(*oa))
]
.spacing(6),
);
}
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
content = content
.push(text("Referenced definitions · reviewed on their own traces").size(14));
match linked_semantic_impacts(project, trace, &self.references) {
Ok(impacts) => {
for impact in impacts {
content = content.push(
text(format!(
"{} ↔ {} · {}{} · {} trace(s)",
impact
.objects
.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
impact
.objects
.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
drift(impact.drift),
if impact.unreviewed {
" · incomplete review"
} else {
""
},
impact.traces.len()
))
.size(12),
);
}
}
Err(error) => content = content.push(text(error).size(12)),
}
}
scrollable(content).height(305).into()
}
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10);
if let Some(plan) = &self.plan {
if plan.impacts.is_empty() {
content =
content.push(text("No additional prepared changes in this preview.").size(12));
}
for (index, impact) in plan.impacts.iter().enumerate() {
let before = impact
.before
.as_ref()
.map(|r| serde_json::to_value(r).expect("Typed impact"));
let after = impact
.after
.as_ref()
.map(|r| serde_json::to_value(r).expect("Typed impact"));
let ids = project.identities().into_iter().map(|(id, _)| id).collect();
content = content.push(
container(
column![
checkbox(self.accepted_impacts.contains(impact))
.label(format!("Accept · {}", project.named(impact.object)))
.text_size(12)
.on_toggle(move |accepted| event(Event::AcceptImpact(
index, accepted
))),
text("Before").size(11).color(style::MUTED),
text(
before
.as_ref()
.map(|v| formatted(project, v, &ids))
.unwrap_or_else(|| "Absent · record creation".into())
)
.size(12),
text("After").size(11).color(style::MUTED),
text(
after
.as_ref()
.map(|v| formatted(project, v, &ids))
.unwrap_or_else(|| "Absent · record deletion".into())
)
.size(12),
]
.spacing(6),
)
.padding(10)
.style(style::subtle),
);
}
}
scrollable(content).height(305).into()
}
}
+4 -1
View File
@@ -250,7 +250,10 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
assert_eq!(app.page, Page::Validation);
assert_eq!(app.project(), &original);
assert!(!app.dirty());
assert!(app.source_snapshot.starts_with("arcadia 4;"));
assert!(
app.source_snapshot
.starts_with(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+36 -9
View File
@@ -247,6 +247,13 @@ pub fn view(app: &App) -> View<'_, Message> {
.style(style::panel);
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
if let Some(workflow) = &app.workflow {
if matches!(workflow, crate::workflow::Workflow::Semantic(_)) {
return modal_width(
root,
workflow.view(p),
(app.window_size.width - 50.).clamp(800., 1120.),
);
}
return modal(root, workflow.view(p));
}
if let Some(p) = &app.file_prompt {
@@ -299,15 +306,27 @@ pub fn view(app: &App) -> View<'_, Message> {
root
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
modal_width(base, content, 800.)
}
fn modal_width<'a>(
base: View<'a, Message>,
content: View<'a, Message>,
width: f32,
) -> View<'a, Message> {
widget::stack![
base,
widget::opaque(
container(container(content).padding(26).width(800).style(style::card))
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
container(
container(content)
.padding(26)
.width(width)
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
@@ -714,10 +733,14 @@ fn traces(app: &App) -> View<'_, Message> {
"Connect operational needs, system responsibilities and development evidence."
),
row![
button(text("Create / update counterpart…").size(12))
button(text("Create counterpart / review prose…").size(12))
.padding(9)
.on_press(Message::StartTransition),
action("+ Manual trace", Message::AddTrace),
action(
"Review semantics…",
Message::StartSemanticReview(app.selected)
),
action("+ Artifact", Message::AddArtifact)
]
.spacing(8)
@@ -740,10 +763,14 @@ fn traces(app: &App) -> View<'_, Message> {
.spacing(8)
.align_y(iced::Center),
row![
label(format!("{:?} · {current}", t.kind)),
label(format!("{:?} · Prose: {current}", t.kind)),
Space::new().width(Fill),
action("Review link", Message::Select(t.id))
action("Review semantics…", Message::StartSemanticReview(t.id)),
action("Edit link", Message::Select(t.id))
],
text(crate::semantic_review::summary(p, t))
.size(11)
.color(style::MUTED),
text(short(&t.description, 130)).size(12)
]
.spacing(10),
+13 -1
View File
@@ -15,6 +15,7 @@ use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub enum Event {
Semantic(crate::semantic_review::Event),
Port(crate::port_workflow::Event),
ExchangeItem(Id, bool),
Endpoint(bool, EndpointChoice),
@@ -33,6 +34,7 @@ pub enum Event {
Preview,
}
pub enum Workflow {
Semantic(Box<crate::semantic_review::State>),
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
@@ -333,6 +335,11 @@ impl Workflow {
/// choices and previews never mutate the Session.
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
match self {
Self::Semantic(_) => {
return Err(
"Semantic review commits its exact command batch through Document.".into(),
);
}
Self::Ports(state) => return state.update(p, event),
Self::ManualTrace {
name,
@@ -571,6 +578,7 @@ impl Workflow {
}
pub fn title(&self) -> &'static str {
match self {
Self::Semantic(_) => "Review semantics",
Self::Ports(state) => state.title(),
Self::ManualTrace { .. } => "Link existing counterparts",
Self::Relation(_) => "Create relationship",
@@ -584,6 +592,9 @@ impl Workflow {
}
}
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
if let Self::Semantic(state) = self {
return state.view(p);
}
if let Self::Ports(state) = self {
return state.view(p);
}
@@ -594,7 +605,7 @@ impl Workflow {
None
};
match self {
Self::Ports(_) => unreachable!("Port dialog returned above"),
Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"),
Self::ManualTrace {
name,
source,
@@ -1073,6 +1084,7 @@ impl Workflow {
}
}
let enabled = match self {
Self::Semantic(_) => false,
Self::Ports(_) => false,
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
+170
View File
@@ -72,6 +72,18 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/transitions/apply",
post(commit_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/query",
post(query_semantic_transitions),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/preview",
post(preview_semantic_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/apply",
post(commit_semantic_transition),
)
.fallback(|| async {
ApiError::new(
StatusCode::NOT_FOUND,
@@ -1089,6 +1101,164 @@ struct TransitionApplyRequest {
plan: oa_core::transitions::TransitionPlan,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticQueryRequest {
traces: Vec<Id>,
#[serde(default)]
references: BTreeMap<Id, Id>,
revision: Option<u64>,
}
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"semantic_transition_rejected",
error.to_string(),
)
}
async fn query_semantic_transitions(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticQueryRequest = json_body(&body)?;
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
}
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
if selected.len() != request.traces.len() {
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
}
let current = store.get(id)?;
check_read_revision(&current, request.revision)?;
let mut items = Vec::new();
for trace_id in request.traces {
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
})?;
let facets = oa_core::semantic::semantic_facets(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let drift = oa_core::semantic::trace_semantic_drift(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let linked_impacts = oa_core::semantic::linked_semantic_impacts(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
}
Ok(response(&current, StatusCode::OK, json!({"revision":current.revision,"items":items})))
}).await
}
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
let transition = &request.transition;
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
count
.checked_add(review.resolutions.len())?
.checked_add(review.reset.len())
});
if transition.sources.len() > 100
|| transition.mappings.len() > 200
|| transition.resolutions.len() > 400
|| request.traces.len() > 100
|| request.references.len() > 5_000
|| request.reviews.len() > 100
|| decisions.is_none_or(|count| count > 5_000)
|| request.candidates.len() > 200
|| request.accepted_impacts.len() > 200
{
return Err(ApiError::bad_request(
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
));
}
Ok(())
}
async fn preview_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
check_semantic_request(&request)?;
let current = store.checked(id, &expected)?;
let plan = oa_core::semantic::plan_semantic_transition(&current.model, request)
.map_err(semantic_rejected)?;
if !plan.commands.is_empty() {
check_commands(&plan.commands)?;
}
if plan.conflicts.is_empty() {
let candidate = oa_core::semantic::prepare_semantic_transition(&current.model, &plan)
.map_err(semantic_rejected)?;
PreparedProject::from_command(&current, candidate)?;
}
Ok(response(
&current,
StatusCode::OK,
json!({"base_revision":current.revision,"plan":plan}),
))
})
.await
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticApplyRequest {
base_revision: u64,
plan: oa_core::semantic::SemanticTransitionPlan,
}
async fn commit_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticApplyRequest = json_body(&body)?;
let current = store.checked(id, &expected)?;
if current.revision != request.base_revision {
let mut error = ApiError::new(
StatusCode::PRECONDITION_FAILED,
"plan_revision_conflict",
"Semantic preview revision changed. Create a new preview.",
);
error.current_revision = Some(current.revision);
return Err(error);
}
check_semantic_request(&request.plan.request)?;
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate =
oa_core::semantic::prepare_semantic_transition(&current.model, &request.plan)
.map_err(semantic_rejected)?;
let prepared = PreparedProject::from_command(&current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
))
})
.await
}
async fn commit_transition(
State(state): State<AppState>,
id: ProjectPath,
+3 -1
View File
@@ -14,6 +14,8 @@ mod data_contracts;
mod port_anchors;
#[path = "http/source_projects.rs"]
mod source_projects;
#[path = "http/semantic_transitions.rs"]
mod semantic_transitions;
fn v2_missing_ports() -> Value {
let mut old: Value = serde_json::from_slice(include_bytes!(
@@ -2002,7 +2004,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
let no_op = server
.client
@@ -185,7 +185,7 @@ async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
@@ -218,7 +218,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
let resource = summary["resource_id"].as_str().unwrap().parse().unwrap();
let stored = server.get(resource).await;
let report = stored.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 4));
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(report.generated_interface_allocations.len(), 1);
assert!(!report.semantic_defaults.is_empty());
let validated: Value = server
@@ -241,7 +241,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
1
);
assert_eq!(stored.source_project.files.len(), 2);
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 4;"));
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 5;"));
for text in [
"// Event rationale",
"// Interface rationale",
@@ -283,9 +283,9 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
let server = TestServer::start(directory.path(), None).await;
let opened = server.get(resource).await;
assert_eq!(opened.revision, 17);
assert_eq!(opened.model.schema_version, 4);
assert_eq!(opened.model.schema_version, 5);
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 4;"));
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 5;"));
assert_pair(&opened);
assert_eq!(std::fs::read(&filename).unwrap(), original);
assert_eq!(
@@ -717,7 +717,7 @@ async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_s
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
let saved = std::fs::read(&filename).unwrap();
server.stop().await;
@@ -0,0 +1,675 @@
use super::*;
use oa_core::{semantic::*, transitions::*, *};
fn fixture() -> Project {
oa_core::persistence::decode(include_bytes!(
"../../../oa-core/tests/fixtures/v4-reviewed-prose.json"
))
.unwrap()
}
fn key(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn request(
source: Id,
target: Id,
decisions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let direction = if source == key(3) {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source,
resolutions: decisions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
request
}
async fn post(
server: &TestServer,
id: Id,
operation: &str,
etag: Option<&str>,
value: &impl serde::Serialize,
) -> reqwest::Response {
let mut call = server
.client
.post(server.url(&format!("/projects/{id}/semantic-transitions/{operation}")))
.json(value);
if let Some(etag) = etag {
call = call.header("if-match", etag);
}
call.send().await.unwrap()
}
async fn ok(response: reqwest::Response) -> Value {
let status = response.status();
let value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
value
}
async fn preview(
server: &TestServer,
stored: &StoredProject,
request: &SemanticTransitionRequest,
) -> Value {
ok(post(
server,
stored.resource_id,
"preview",
Some(&stored.etag()),
request,
)
.await)
.await
}
fn assert_pair(stored: &StoredProject) {
assert_eq!(
oa_core::source_project::compile_bundle(&stored.source_project, Default::default())
.unwrap()
.decoded
.project,
stored.model
);
}
#[tokio::test]
async fn semantic_partial_review_query_source_and_restart_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, _) = server.import(&fixture()).await;
let mut stored = server.get(id).await;
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Authored review rationale\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", stored.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before_bytes = std::fs::read(&disk_path).unwrap();
let query = json!({"traces":[key(4)],"references":{},"revision":stored.revision});
let queried = ok(post(&server, id, "query", None, &query).await).await;
assert_eq!(queried["items"][0]["drift"]["own"], "Unreviewed");
let req = request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::TakeSource,
),
],
);
let plan = preview(&server, &stored, &req).await;
assert_eq!(plan["plan"]["conflicts"], json!([]));
assert_eq!(
plan["plan"]["reviews"][0]["acknowledged"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before_bytes);
assert_eq!(server.get(id).await.etag(), stored.etag());
let applied = ok(post(&server, id, "apply", Some(&stored.etag()), &plan).await).await;
assert_eq!(applied["revision"], stored.revision + 1);
let committed = server.get(id).await;
assert_pair(&committed);
assert!(
committed.source_project.files[&committed.source_project.entry]
.contains("// Authored review rationale")
);
assert_eq!(committed.model.elements[&key(3)].name, "OA dimension now");
assert!(
matches!(committed.model.elements[&key(3)].shared(), Some(SharedBody::DataType { unit, definition: DataTypeDefinition::Real { .. }, .. }) if unit == "ft")
);
let queried = ok(post(&server, id, "query", None, &json!({"traces":[key(4)]})).await).await;
let facts: Vec<FacetDrift> =
serde_json::from_value(queried["items"][0]["facets"].clone()).unwrap();
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Unit))
.unwrap()
.drift,
Drift::Unchanged
);
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Constructor))
.unwrap()
.drift,
Drift::Unreviewed
);
assert!(
queried["items"][0]["drift"]["own_unreviewed"]
.as_bool()
.unwrap()
);
let repeat = preview(&server, &committed, &req).await;
let reapplied = ok(post(&server, id, "apply", Some(&committed.etag()), &repeat).await).await;
assert_eq!(reapplied["changed"], false);
assert_eq!(reapplied["revision"], committed.revision);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(id).await;
assert_eq!(reopened.model, committed.model);
assert_eq!(reopened.source_project, committed.source_project);
assert_eq!(reopened.etag(), committed.etag());
server.stop().await;
}
#[tokio::test]
async fn semantic_strict_rejection_stale_plans_and_source_only_changes_never_write() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, etag) = server.import(&fixture()).await;
let stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&disk_path).unwrap();
let req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::TakeSource,
)],
);
let plan = preview(&server, &stored, &req).await;
for operation in ["preview", "apply"] {
let body = if operation == "preview" {
serde_json::to_value(&req).unwrap()
} else {
plan.clone()
};
assert_eq!(
post(&server, id, operation, None, &body).await.status(),
StatusCode::PRECONDITION_REQUIRED
);
}
let mut malformed = serde_json::to_value(&req).unwrap();
malformed["reviews"][0]["resolutions"][0]["facet"] = json!({"Data":"arbitrary_path"});
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
malformed = serde_json::to_value(&req).unwrap();
malformed["unexpected"] = true.into();
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
for (query, expected) in [
(json!({"traces":[]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(4),key(4)]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(99)]}), StatusCode::NOT_FOUND),
(
json!({"traces":[key(4)],"revision":99}),
StatusCode::CONFLICT,
),
] {
assert_eq!(
post(&server, id, "query", None, &query).await.status(),
expected
);
}
for mutation in [
"commands",
"base_fingerprint",
"prepared_fingerprint",
"base_revision",
] {
let mut rejected = plan.clone();
let expected = if mutation == "base_revision" {
rejected[mutation] = 99.into();
StatusCode::PRECONDITION_FAILED
} else {
rejected["plan"][mutation] = if mutation == "commands" {
json!([])
} else {
json!("altered")
};
StatusCode::UNPROCESSABLE_ENTITY
};
assert_eq!(
post(&server, id, "apply", Some(&etag), &rejected)
.await
.status(),
expected
);
}
let mut duplicate = req.clone();
let repeated = duplicate.reviews[0].resolutions[0].clone();
duplicate.reviews[0].resolutions.push(repeated);
let conflicted = preview(&server, &stored, &duplicate).await;
assert!(
!conflicted["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(
post(&server, id, "apply", Some(&etag), &conflicted)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before);
assert_eq!(server.get(id).await.etag(), etag);
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("\n// Source-only concurrent edit\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", &etag)
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
let current = server.get(id).await;
assert_eq!(current.model, stored.model);
let bytes = std::fs::read(&disk_path).unwrap();
for precondition in [&etag, &current.etag()] {
assert_eq!(
post(&server, id, "apply", Some(precondition), &plan)
.await
.status(),
StatusCode::PRECONDITION_FAILED
);
}
assert_eq!(std::fs::read(&disk_path).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn semantic_candidate_constructor_reverse_direction_and_explicit_trace_selection() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = fixture();
let mut second = model.traces[&key(4)].clone();
second.id = key(5);
second.alias = "second_review".into();
second.kind = TraceKind::Manual;
model.traces.insert(second.id, second);
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let mut req = request(
key(3),
key(2),
vec![
(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
),
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
),
],
);
let ambiguous = preview(&server, &stored, &req).await;
assert!(
!ambiguous["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
req.traces.insert(key(3), key(5));
let mut candidate = model.elements[&key(2)].clone();
*candidate.shared_mut().unwrap() = model.elements[&key(3)].shared().unwrap().clone();
if let SharedBody::DataType { unit, .. } = candidate.shared_mut().unwrap() {
*unit = "cm".into();
}
req.candidates
.push(SemanticRecord::Element(candidate.clone()));
req.references.insert(key(900), key(901)); // Historical correspondence survives transport unchanged.
let envelope = preview(&server, &stored, &req).await;
assert_eq!(
envelope["plan"]["request"],
serde_json::to_value(&req).unwrap()
);
assert_eq!(envelope["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
let applied = server.get(id).await;
assert_eq!(applied.model.elements[&key(2)], candidate);
assert!(
applied.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_none()
);
assert!(
applied.model.traces[&key(5)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
assert_pair(&applied);
server.stop().await;
}
#[tokio::test]
async fn semantic_new_mapping_keeps_preview_generated_identifiers() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = Project::empty();
let source = model
.add_element(Kind::Activity, Layer::Oa, "Inspect", None)
.unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let req = SemanticTransitionRequest::new(TransitionRequest::new(
TransitionDirection::OaToSa,
vec![source],
));
let envelope = preview(&server, &stored, &req).await;
assert_eq!(envelope["plan"]["conflicts"], json!([]));
let plan: SemanticTransitionPlan = serde_json::from_value(envelope["plan"].clone()).unwrap();
let expected = oa_core::semantic::apply_semantic_transition(&model, &plan).unwrap();
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
assert_eq!(server.get(id).await.model, expected);
server.stop().await;
}
#[tokio::test]
async fn semantic_resets_and_invalid_candidates_preserve_deliberate_review_boundaries() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let model = fixture();
let reviewed = oa_core::semantic::plan_semantic_transition(
&model,
request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::KeepTarget,
),
],
),
)
.unwrap();
let model = oa_core::semantic::apply_semantic_transition(&model, &reviewed).unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let before = std::fs::read(directory.path().join(format!("{id}.json"))).unwrap();
let mut req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
)],
);
let mut candidate = model.elements[&key(3)].clone();
candidate.name = "Unselected incidental edit".into();
req.candidates.push(SemanticRecord::Element(candidate));
let invalid = preview(&server, &stored, &req).await;
assert!(!invalid["plan"]["conflicts"].as_array().unwrap().is_empty());
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &invalid)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
before
);
req = request(key(2), key(3), vec![]);
req.reviews[0]
.reset
.push(FacetSelector::Data(DataFacet::Unit));
let reset = preview(&server, &stored, &req).await;
assert_eq!(
reset["plan"]["request"]["reviews"][0]["reset"],
json!([{"Data":"Unit"}])
);
assert_eq!(reset["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &reset).await).await;
let current = server.get(id).await;
let facts = &current.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].selector(),
FacetSelector::Header(HeaderFacet::Name)
);
assert_eq!(current.model.elements, model.elements);
assert_pair(&current);
server.stop().await;
}
fn cleanup_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Source record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "Target record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Base record", None)
.unwrap();
for (id, supertype) in [(oa, None), (base, None), (sa, Some(base))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Coordinate", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Location", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Sa, "Payload", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (field, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let sender = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let receiver = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Location report",
sender,
receiver,
);
let RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} = &mut p.relations.get_mut(&exchange).unwrap().body
else {
panic!()
};
items.push(item);
for id in [*source_port, *target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Report location", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Report", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![field],
field: leaf,
expression: "10".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "record_trace".into(),
name: "Record correspondence".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
(p, oa, sa, message)
}
#[tokio::test]
async fn semantic_cleanup_impacts_require_exact_acceptance_before_atomic_apply() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (model, oa, sa, message) = cleanup_fixture();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&path).unwrap();
let mut req = request(
oa,
sa,
vec![(
FacetSelector::Data(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
)],
);
let unresolved = preview(&server, &stored, &req).await;
assert!(
!unresolved["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
let impacts: Vec<SemanticImpact> =
serde_json::from_value(unresolved["plan"]["impacts"].clone()).unwrap();
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
assert_ne!(impacts[0].before, impacts[0].after);
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &unresolved)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
let mut inaccurate = impacts.clone();
inaccurate[0].after = inaccurate[0].before.clone();
req.accepted_impacts = inaccurate;
let inaccurate = preview(&server, &stored, &req).await;
assert!(
!inaccurate["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(std::fs::read(&path).unwrap(), before);
req.accepted_impacts = impacts;
let approved = preview(&server, &stored, &req).await;
assert_eq!(approved["plan"]["conflicts"], json!([]));
assert_eq!(
approved["plan"]["request"]["accepted_impacts"],
serde_json::to_value(&req.accepted_impacts).unwrap()
);
ok(post(&server, id, "apply", Some(&stored.etag()), &approved).await).await;
let current = server.get(id).await;
assert_eq!(current.revision, stored.revision + 1);
assert!(
matches!(current.model.elements[&message].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.is_empty())
);
assert_pair(&current);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.model, current.model);
server.stop().await;
}
@@ -0,0 +1,136 @@
"""Validate actual local HTTP responses against served and checked-in OpenAPI.
Requires jsonschema. Usage: python semantic_http_contract.py /path/to/arcadia-server
The supplied binary is launched on loopback with a fresh temporary store.
"""
import copy
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
ROOT = Path(__file__).resolve().parents[3]
local = json.loads((ROOT / "docs/openapi.json").read_text(encoding="utf-8"))
for schema in local["components"]["schemas"].values():
Draft202012Validator.check_schema(schema)
def validator(spec, name):
return Draft202012Validator({"$ref": "#/components/schemas/" + name, "components": spec["components"]},
format_checker=FormatChecker())
with tempfile.TemporaryDirectory(prefix="arcadia-semantic-http-") as directory:
env = dict(os.environ)
env.pop("ARCADIA_SERVER_TOKEN", None)
process = subprocess.Popen([sys.argv[1], "--bind", "127.0.0.1:0", "--data-dir", directory],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
try:
banner = process.stderr.readline()
address = re.search(r"listening on (127\.0\.0\.1:\d+)", banner)
assert address, banner
base = "http://" + address.group(1)
def call(method, path, body=None, etag=None, expected=200):
headers = {"Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
request = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(),
headers=headers, method=method)
try:
response = urllib.request.urlopen(request, timeout=15)
except urllib.error.HTTPError as error:
response = error
with response:
data = json.load(response)
assert response.status == expected, (response.status, data)
return data, response.headers.get("ETag")
served, _ = call("GET", "/openapi.json")
assert served == local, "Server binary embeds a different OpenAPI file; rebuild it"
checked = 0
def validate(name, value):
global checked
for spec in (local, served):
validator(spec, name).validate(value)
checked += 1
old = json.loads((ROOT / "crates/oa-core/tests/fixtures/v4-reviewed-prose.json").read_text())
imported, etag = call("POST", "/api/v1/projects/import", old, expected=201)
path = "/api/v1/projects/" + imported["resource_id"]
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
trace = next(iter(stored["model"]["traces"]))
oa = stored["model"]["traces"][trace]["oa"]
sa = stored["model"]["traces"][trace]["sa"]
query = {"traces": [trace], "references": {oa: sa}, "revision": stored["revision"]}
validate("SemanticQueryRequest", query)
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
request = {"transition": {"direction": "OaToSa", "sources": [oa], "scope": "SelectedOnly",
"mappings": [{"source": oa, "target": sa, "target_kind": None}], "fields": [],
"resolutions": [], "destination_diagram": None}, "traces": {oa: trace},
"references": {}, "reviews": [{"source": oa, "resolutions": [
{"facet": {"Header": "Name"}, "decision": "TakeSource"},
{"facet": {"Data": "Unit"}, "decision": "KeepTarget"}], "reset": []}],
"candidates": [], "accepted_impacts": []}
validate("SemanticTransitionRequest", request)
one_sided = copy.deepcopy(request)
one_sided["reviews"][0]["resolutions"] = [{"facet": {"Literal": {"oa": "Only source"}}, "decision": "KeepTarget"}]
validate("SemanticTransitionRequest", one_sided)
admitted, _ = call("POST", path + "/semantic-transitions/preview", one_sided, etag)
validate("SemanticTransitionEnvelope", admitted)
assert admitted["plan"]["conflicts"] # Valid typed input, inapplicable to the integer/real fixture.
preview, _ = call("POST", path + "/semantic-transitions/preview", request, etag)
validate("SemanticTransitionEnvelope", preview)
assert not preview["plan"]["conflicts"], preview
result, updated_etag = call("POST", path + "/semantic-transitions/apply", preview, etag)
validate("MutationResult", result)
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
baseline = stored["model"]["traces"][trace]["baseline"]
validate("TraceBaseline", baseline)
validate("SemanticBaseline", baseline["semantic"])
query.pop("revision")
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
problem, _ = call("POST", path + "/semantic-transitions/apply", preview, etag, expected=412)
validate("Problem", problem)
assert updated_etag != etag
invalid = [
("FacetSelector", {"path": ["definition", "minimum"]}),
("FacetSelector", {"Data": "Unsupported"}),
("FacetReview", {"Data": {"IntegerMinimum": {"oa": {"Value": "wrong"}, "sa": {"Value": None}}}}),
("FacetReview", {"Data": {"Unit": {"oa": {"Value": 42}, "sa": "NotApplicable"}}}),
("FacetSelector", {"Literal": {"oa": "literal", "sa": None, "index": 0}}),
("SemanticQueryRequest", {"traces": [trace, trace]}),
("SemanticQueryRequest", {"traces": []}),
("SemanticTransitionEnvelope", {**preview, "unknown": True}),
]
obsolete = copy.deepcopy(stored["model"])
obsolete["schema_version"] = 4
invalid.append(("Project", obsolete))
for name, value in invalid:
assert not validator(local, name).is_valid(value), (name, value)
assert not validator(served, name).is_valid(value), (name, value)
# NotApplicable is distinct from Value(null); each leaf fixes the optionality.
validate("FacetReview", {"Data": {"IntegerMinimum": {"oa": "NotApplicable", "sa": {"Value": None}}}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}, "sa": None}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}}})
validate("SemanticImpact", {"object": oa, "after": {"Element": stored["model"]["elements"][oa]}})
print(f"{len(local['components']['schemas'])} valid schemas; {checked} actual/typed values validated against served+local contracts; {len(invalid)} invalid shapes rejected")
finally:
process.terminate()
process.wait(timeout=15)
+205
View File
@@ -0,0 +1,205 @@
"""Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table.
Run from any directory with Python 3: semantic_schema.py [--check].
This generates documentation only, never runtime selectors or model mutations.
"""
import copy
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PATH = ROOT / "docs/openapi.json"
spec = json.loads(PATH.read_text(encoding="utf-8"))
schemas = spec["components"]["schemas"]
types = (ROOT / "crates/oa-core/src/semantic/types.rs").read_text(encoding="utf-8")
model = (ROOT / "crates/oa-core/src/model.rs").read_text(encoding="utf-8")
def ref(name):
return {"$ref": f"#/components/schemas/{name}"}
def obj(properties, required=None):
return {"type": "object", "properties": properties,
"required": list(properties) if required is None else required,
"additionalProperties": False}
def array(item, maximum=None):
result = {"type": "array", "items": item}
if maximum is not None:
result["maxItems"] = maximum
return result
def optional(item):
return {"anyOf": [item, {"type": "null"}]}
def enum(names):
return {"type": "string", "enum": names}
def variant(name, value):
return obj({name: value})
def typed(name):
if name.startswith("Option<"):
return optional(typed(name[7:-1]))
if name.startswith("Vec<"):
return array(typed(name[4:-1]))
primitives = {"String": {"type": "string"}, "bool": {"type": "boolean"},
"u32": {"type": "integer", "minimum": 0, "maximum": 4294967295},
"i64": {"type": "integer", "minimum": -9223372036854775808,
"maximum": 9223372036854775807}, "f64": {"type": "number"}}
return primitives[name] if name in primitives else ref(name)
def reviewed(name):
key = "Reviewed" + re.sub(r"[^A-Za-z0-9]", "", name)
observation = {"oneOf": [enum(["NotApplicable"]), variant("Value", typed(name))]}
schemas[key] = obj({"oa": observation, "sa": copy.deepcopy(observation)})
return ref(key)
def keypair(name):
key = "KeyPair" + name
schemas[key] = obj({"oa": optional(typed(name)), "sa": optional(typed(name))}, [])
return ref(key)
for name in ["DataConstructor", "PortConstructor", "OccurrenceConstructor", "EventConstructor",
"MessageConstructor", "ReferenceCollection", "OrderedCollection", "IncidentEnd",
"ArtifactFacet", "ComponentPortKind", "ComponentPortOrientation", "PortDirection",
"FragmentOperator", "StateActionKind", "ArtifactRelation"]:
source = types if f"pub enum {name}" in types else model
body = re.search(r"pub enum " + name + r"\s*\{([^}]+)\}", source).group(1)
body = re.sub(r"//[^\n]*|#\[[^\]]*\]", "", body)
names = [value.strip() for value in body.split(",") if value.strip()]
assert all(re.fullmatch(r"[A-Za-z]+", value) for value in names), (name, names)
schemas[name] = enum(names)
catalog = types.split("macro_rules! scalar_families", 1)[1].split("pub(super) use", 1)[0]
families = re.findall(r"(\w+), (\w+), (\w+) \{([^}]+)\}", catalog)
assert len(families) == 13
selectors, reviews = [], []
leaf_count = 0
for family, selector, review, body in families:
leaves = re.findall(r"(\w+): ([\w<>]+)", body)
leaf_count += len(leaves)
schemas[selector] = enum([leaf for leaf, _ in leaves])
schemas[review] = {"oneOf": [variant(leaf, reviewed(value)) for leaf, value in leaves]}
selectors.append(variant(family, ref(selector)))
reviews.append(variant(family, ref(review)))
assert leaf_count == 84
schemas["AnnotationKey"] = obj({"path": array(ref("Id")), "field": ref("Id")})
id_keys, text_keys, annotation_keys = keypair("Id"), keypair("String"), keypair("AnnotationKey")
artifact_types = {"Presence": "bool", "Name": "String", "Description": "String", "Owner": "Id",
"Relation": "ArtifactRelation", "Uri": "String", "Revision": "Option<String>",
"Fragment": "Option<String>"}
schemas["ArtifactReview"] = {"oneOf": [variant(k, reviewed(v)) for k, v in artifact_types.items()]}
selectors += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys})),
variant("Order", ref("OrderedCollection")), variant("Literal", text_keys),
variant("Annotation", annotation_keys), variant("Binding", id_keys), variant("Child", id_keys),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd")})),
variant("Artifact", obj({"keys": id_keys, "facet": ref("ArtifactFacet")}))]
reviews += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys, "review": reviewed("bool")})),
variant("Order", obj({"collection": ref("OrderedCollection"), "review": reviewed("Vec<Id>")})),
variant("Literal", obj({"keys": text_keys, "review": reviewed("bool")})),
variant("Annotation", obj({"keys": annotation_keys, "review": reviewed("Option<MessageValue>")})),
variant("Binding", obj({"keys": id_keys, "review": reviewed("Option<LifelineBinding>")})),
variant("Child", obj({"keys": id_keys, "review": reviewed("bool")})),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd"), "review": reviewed("bool")})),
variant("Artifact", obj({"keys": id_keys, "review": ref("ArtifactReview")}))]
schemas["FacetSelector"] = {"oneOf": selectors}
schemas["FacetReview"] = {"oneOf": reviews}
schemas["SemanticBaseline"] = obj({"facets": array(ref("FacetReview"))})
schemas["TraceBaseline"]["properties"]["semantic"] = {**optional(ref("SemanticBaseline")), "default": None,
"description": "Absent/null means Unreviewed. Historical typed facts never become live graph references."}
schemas["Project"]["properties"]["schema_version"]["const"] = 5
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4]
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 5
schemas["MigrationReport"]["description"] = schemas["MigrationReport"]["description"].replace("V1/V2/V3 to V4", "V1/V2/V3/V4 to V5")
spec["info"]["description"] = spec["info"]["description"].replace("schema-4", "schema-5")
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-5 native JSON is described below. Schema-1/2/3/4 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Prior prose reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 5 / arcadia 5. See docs/language.rst."
schemas["SourceBundle"]["description"] = schemas["SourceBundle"]["description"].replace("source4 workspace (source3 migrates explicitly)", "source5 workspace (source3/4 migrate explicitly)")
schemas["Drift"] = enum(["Unreviewed", "Unchanged", "OaChanged", "SaChanged", "BothChanged"])
schemas["FacetDrift"] = obj({"selector": ref("FacetSelector"), "current": ref("FacetReview"), "drift": ref("Drift")})
schemas["SemanticDrift"] = obj({"own": ref("Drift"), "linked": ref("Drift"),
"own_unreviewed": typed("bool"), "linked_unreviewed": typed("bool")})
schemas["LinkedSemanticImpact"] = obj({"objects": id_keys, "traces": array(ref("Id")),
"drift": ref("Drift"), "unreviewed": typed("bool")})
for name, members in [("SemanticRecord", ["Element", "Relation", "Artifact"]),
("ImpactRecord", ["Element", "Relation", "Artifact", "Diagram", "Trace"])]:
schemas[name] = {"oneOf": [variant(n, ref("ArtifactLink" if n == "Artifact" else n)) for n in members]}
schemas["SemanticImpact"] = obj({"object": ref("Id"), "before": optional(ref("ImpactRecord")), "after": optional(ref("ImpactRecord"))}, ["object"])
schemas["SemanticDecision"] = enum(["KeepTarget", "TakeSource", "Candidate"])
schemas["FacetResolution"] = obj({"facet": ref("FacetSelector"), "decision": ref("SemanticDecision")})
schemas["SemanticReviewRequest"] = obj({"source": ref("Id"), "resolutions": array(ref("FacetResolution"), 5000),
"reset": {**array(ref("FacetSelector"), 5000), "default": []}}, ["source", "resolutions"])
def mappings(maximum):
return {"type": "object", "propertyNames": {"format": "uuid"},
"additionalProperties": ref("Id"), "maxProperties": maximum, "default": {}}
schemas["SemanticTransitionRequest"] = obj({"transition": ref("TransitionRequest"), "traces": mappings(100),
"references": mappings(5000), "reviews": {**array(ref("SemanticReviewRequest"), 100), "default": []},
"candidates": {**array(ref("SemanticRecord"), 200), "default": []},
"accepted_impacts": {**array(ref("SemanticImpact"), 200), "default": []}}, ["transition"])
schemas["SemanticTransitionRequest"]["description"] = "Canonical OA-to-SA references include historical UUIDs. Traces map selected source IDs to trace IDs. Combined resolutions/reset limit: 5,000. Candidates must equal exactly the result of their selected facets; accepted_impacts explicitly acknowledge exact induced changes."
schemas["SemanticReviewPreview"] = obj({"source": ref("Id"), "target": ref("Id"), "facets": array(ref("FacetDrift")),
"acknowledged": array(ref("FacetSelector")), "retired": array(ref("FacetSelector")), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticTransitionPlan"] = obj({"base_fingerprint": typed("String"), "request": ref("SemanticTransitionRequest"),
"transition": ref("TransitionPlan"), "reviews": array(ref("SemanticReviewPreview")), "impacts": array(ref("SemanticImpact")),
"conflicts": array(typed("String")), "commands": array(ref("Command"), 200), "prepared_fingerprint": optional(typed("String"))})
schemas["SemanticTransitionPlan"]["required"].remove("prepared_fingerprint")
schemas["SemanticTransitionEnvelope"] = obj({"base_revision": ref("Revision"), "plan": ref("SemanticTransitionPlan")})
schemas["SemanticQueryRequest"] = obj({"traces": {**array(ref("Id"), 100), "minItems": 1, "uniqueItems": True},
"references": mappings(5000), "revision": optional(ref("Revision"))}, ["traces"])
schemas["SemanticQueryItem"] = obj({"trace": ref("Id"), "oa": ref("Id"), "sa": ref("Id"),
"facets": array(ref("FacetDrift")), "drift": ref("SemanticDrift"), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticQueryResult"] = obj({"revision": ref("Revision"), "items": array(ref("SemanticQueryItem"), 100)})
for operation, request, result in [("query", "SemanticQueryRequest", "SemanticQueryResult"),
("preview", "SemanticTransitionRequest", "SemanticTransitionEnvelope"),
("apply", "SemanticTransitionEnvelope", "MutationResult")]:
endpoint = copy.deepcopy(spec["paths"]["/api/v1/projects/{id}/transitions/" + ("apply" if operation == "apply" else "preview")])
post = endpoint["post"]
post["operationId"] = operation + "SemanticTransition"
post["summary"] = {"query": "Query typed trace drift and linked impacts without mutation",
"preview": "Preview and source-validate a typed semantic transition without writing",
"apply": "Conditionally apply a prepared semantic candidate in one model/source revision"}[operation]
post["requestBody"]["content"]["application/json"]["schema"] = ref(request)
post["responses"]["200"]["content"]["application/json"]["schema"] = ref(result)
post["description"] = "Uses the frozen core typed semantic contract. A supplied plan is a client-owned candidate, not a signed receipt; fingerprints check consistency, not original review authorship. Preview validates source writeback when conflict-free. Apply preserves preview-generated IDs and does not replan random identities."
if operation == "query":
post["parameters"] = [p for p in post["parameters"] if p["in"] != "header"]
post["responses"].pop("412", None)
post["responses"].pop("428", None)
post["responses"]["409"] = {"$ref": "#/components/responses/Problem"}
post["description"] = "Read one immutable snapshot. Optional revision mismatch returns409; missing trace returns404; duplicate/empty trace selection returns400. References are canonical OA-to-SA correspondence. No write, baseline refresh or external artifact fetch."
spec["paths"]["/api/v1/projects/{id}/semantic-transitions/" + operation] = endpoint
def check_refs(value):
if isinstance(value, dict):
if "$ref" in value and value["$ref"].startswith("#/components/schemas/"):
assert value["$ref"].split("/")[-1] in schemas, value
for child in value.values():
check_refs(child)
elif isinstance(value, list):
for child in value:
check_refs(child)
check_refs(spec)
rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
if "--check" in sys.argv:
assert json.loads(PATH.read_text(encoding="utf-8")) == spec, "Run semantic_schema.py to refresh OpenAPI"
else:
PATH.write_text(rendered, encoding="utf-8")
print(f"Closed catalog: {len(families)} families, {leaf_count} scalar leaves; {len(schemas)} schemas")