Integrate semantic review core source API and scalar desktop workflow
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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("e(value));
|
||||
}
|
||||
Some(HistoricalSourceKind::Enum) => out.push_str(value),
|
||||
None => out.push_str("e(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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¤t_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
@@ -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>,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(¤t_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),
|
||||
(
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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"; };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
¤t.decoded.project,
|
||||
&Command::Reidentify(id(2), id(20)),
|
||||
)
|
||||
.unwrap();
|
||||
facets(&mut prepared.project).reverse();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&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(¤t.decoded.project, &Command::Delete(id(90))).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&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(¤t.decoded.project, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let prepared = prepare_semantic_transition(¤t.decoded.project, &plan).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(¤t, 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(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let drift = oa_core::semantic::trace_semantic_drift(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let linked_impacts = oa_core::semantic::linked_semantic_impacts(¤t.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(¤t, 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(¤t.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(¤t.model, &plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
PreparedProject::from_command(¤t, candidate)?;
|
||||
}
|
||||
Ok(response(
|
||||
¤t,
|
||||
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(¤t.model, &request.plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
let prepared = PreparedProject::from_command(¤t, 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,
|
||||
|
||||
@@ -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, ¤t.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 = ¤t.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(¤t);
|
||||
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(¤t);
|
||||
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)
|
||||
@@ -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")
|
||||
+114
-6
@@ -763,6 +763,18 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/transitions/apply</span></span></p></td>
|
||||
<td><p>Conditionally apply that client-held plan and base_revision.</p></td>
|
||||
</tr>
|
||||
<tr><td><p>POST</p></td>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/query</span></span></p></td>
|
||||
<td><p>Read selected traces' typed facts, own drift and linked impacts.</p></td>
|
||||
</tr>
|
||||
<tr><td><p>POST</p></td>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/preview</span></span></p></td>
|
||||
<td><p>Prepare typed semantic decisions and source writeback without writing.</p></td>
|
||||
</tr>
|
||||
<tr><td><p>POST</p></td>
|
||||
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/apply</span></span></p></td>
|
||||
<td><p>Apply that prepared candidate under its original shared revision/ETag.</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><span class="docutils literal"><span class="pre">Content-Type:</span> application/json</span> is required for JSON requests.
|
||||
@@ -784,8 +796,8 @@ candidate validation reports the same diagnostic in its 200 response.
|
||||
Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.</p>
|
||||
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
|
||||
@@ -796,7 +808,7 @@ Only documented query parameters are accepted. In particular, appending
|
||||
<pre class="literal-block">{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 5; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}</pre>
|
||||
@@ -807,7 +819,7 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.</p>
|
||||
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
|
||||
@@ -999,7 +1011,7 @@ contract in the same transaction when removing a used interface allocation.</p>
|
||||
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
current exports use schema5/source5. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
</section>
|
||||
<section id="validation">
|
||||
@@ -1009,7 +1021,7 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"arcadia", "source":"complete source document"}</pre>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
@@ -1093,6 +1105,102 @@ authenticated as an untouched server preview; the same authenticated client
|
||||
can submit ordinary typed commands. The core fingerprint is a change detector,
|
||||
not a security digest. There is no plan token, expiration store or receipt.</p>
|
||||
</section>
|
||||
<section id="typed-semantic-transition-review">
|
||||
<h2>Typed semantic transition review</h2>
|
||||
<p>The sibling <span class="docutils literal"><span class="pre">semantic-transitions</span></span> routes use the <a class="reference external" href="language.html#typed-semantic-review-facts">typed review semantics</a> in <span class="docutils literal">language.rst</span> sections 5.1–5.5.
|
||||
The original prose transition routes keep
|
||||
their existing contract. All new routes use JSON and the existing authentication,
|
||||
capacity, worker and error boundaries.</p>
|
||||
<p>Query accepts <span class="docutils literal"><span class="pre">{"traces":["trace-uuid"],"references":{},"revision":1}</span></span>.
|
||||
<span class="docutils literal">references</span> is an optional map of canonical OA UUIDs to SA UUIDs, including
|
||||
historical deleted identities. It never guesses correspondences from names.
|
||||
<span class="docutils literal">revision</span> is optional; a mismatch returns 409. Select 1–100 unique existing
|
||||
trace IDs; an empty or duplicate selection returns 400 and a missing trace
|
||||
returns 404. The response includes the current revision and items containing
|
||||
<span class="docutils literal">trace</span>, <span class="docutils literal">oa</span>, <span class="docutils literal">sa</span>, typed <span class="docutils literal">facets</span>, <span class="docutils literal">drift</span> and <span class="docutils literal">linked_impacts</span>.
|
||||
Own and linked drift and their unreviewed flags remain distinct. Query reads one
|
||||
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.</p>
|
||||
<p>Preview accepts <span class="docutils literal">SemanticTransitionRequest</span>:</p>
|
||||
<pre class="literal-block">{
|
||||
"transition": {
|
||||
"direction": "OaToSa",
|
||||
"sources": ["40000000-0000-4000-8000-000000000002"],
|
||||
"scope": "SelectedOnly",
|
||||
"mappings": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"target": "40000000-0000-4000-8000-000000000003",
|
||||
"target_kind": null
|
||||
}],
|
||||
"fields": [], "resolutions": [], "destination_diagram": null
|
||||
},
|
||||
"traces": {}, "references": {},
|
||||
"reviews": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"resolutions": [
|
||||
{"facet":{"Header":"Name"}, "decision":"TakeSource"},
|
||||
{"facet":{"Data":"Unit"}, "decision":"KeepTarget"}
|
||||
],
|
||||
"reset": []
|
||||
}],
|
||||
"candidates": [], "accepted_impacts": []
|
||||
}</pre>
|
||||
<p>The example maps an existing OA data type to its traced SA counterpart, takes
|
||||
only its name and acknowledges intentionally different units. Other facets stay
|
||||
unreviewed or retain their earlier drift. Select an explicit trace through the
|
||||
<span class="docutils literal">traces</span> map (source UUID to trace UUID) when multiple trace kinds connect the
|
||||
pair. <span class="docutils literal">reset</span> deliberately invalidates exact old selectors after changed
|
||||
correspondence. The optional collections default to empty. Semantic header
|
||||
decisions use typed facets; leave legacy <span class="docutils literal">transition.fields</span> and
|
||||
<span class="docutils literal">transition.resolutions</span> empty.</p>
|
||||
<p>Facet selectors are closed enums, for example <span class="docutils literal"><span class="pre">{"Data":"IntegerMinimum"}</span></span>
|
||||
or <span class="docutils literal"><span class="pre">{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}</span></span>.
|
||||
Annotation keys contain the entire path and field; enum literal keys are exact
|
||||
strings. <span class="docutils literal">KeepTarget</span>, <span class="docutils literal">TakeSource</span> and <span class="docutils literal">Candidate</span> operate on independently
|
||||
selected facts. Candidate records use existing Element/Relation/ArtifactLink
|
||||
types, must preserve identity/alias, and must equal precisely the result of
|
||||
the selected facets. Constructor review covers its coupled introduced/removed
|
||||
fields. Membership review does not silently acknowledge order; target-only
|
||||
members remain independent. OpenAPI defines every selector and typed payload.</p>
|
||||
<p>Preview requires <span class="docutils literal"><span class="pre">If-Match</span></span> and returns <span class="docutils literal"><span class="pre">{"base_revision":1,"plan":{...}}</span></span>.
|
||||
The plan contains the original typed request, mapping plan, review facts,
|
||||
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
|
||||
commands, and base/prepared fingerprints. A conflict-free preview also prepares
|
||||
the complete authored source bundle and checks its model equivalence and capacity.
|
||||
No preview changes the model, source, revision or stored bytes.</p>
|
||||
<p>If ordinary preparation induces additional cleanup, the plan reports exact typed
|
||||
before/after impacts and a conflict. Review those impacts, place the accepted
|
||||
records in <span class="docutils literal">accepted_impacts</span>, and request another preview. Acceptance cannot
|
||||
override a contradictory selected facet. Missing mappings, ambiguous order,
|
||||
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
|
||||
This is the shared core planner and command preparation, with no server-specific
|
||||
semantic mutation engine.</p>
|
||||
<p>Apply accepts the entire preview envelope under the same <span class="docutils literal"><span class="pre">If-Match</span></span>. It checks
|
||||
<span class="docutils literal">base_revision</span>, the model fingerprint, unresolved conflicts, the prepared
|
||||
candidate fingerprint, ordinary model validation and coupled source preparation,
|
||||
then atomically stores one accepted model/source revision. The final store check
|
||||
also catches a concurrent edit during preparation. Source-only edits invalidate
|
||||
the preview through the shared ETag even when the model fingerprint is unchanged.
|
||||
Stale ETag/revision returns 412; semantic rejection returns 422 with code
|
||||
<span class="docutils literal">semantic_transition_rejected</span>. Exact no-op candidates preserve revision.
|
||||
Read and preview again after a successful changed apply before reviewing further.</p>
|
||||
<p>A supplied plan remains a client-owned candidate. Fingerprints detect stale or
|
||||
inconsistent proposals; they are not tamper-proof signatures, authentication
|
||||
receipts or proof of original review authorship. Apply preserves preview-generated
|
||||
identities and does not rerun the planner with fresh random IDs. Authenticated
|
||||
clients already have ordinary model/source editing privileges. The server does
|
||||
not persist plans, tokens or review sessions.</p>
|
||||
<p>Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
|
||||
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
|
||||
resolutions plus resets across all reviews. The legacy 400 prose-resolution
|
||||
limit and 200-command transaction limit still apply. Query has the same 5,000
|
||||
reference-map limit. Core bounded linked traversal errors remain observable as
|
||||
semantic rejection; no truncated result silently claims complete review.</p>
|
||||
<p>Schema/source 5 adds <span class="docutils literal">TraceBaseline.semantic</span>. Absence or null is Unreviewed;
|
||||
migrating older prose baselines does not fabricate semantic history. The persisted
|
||||
baseline contains only closed typed <span class="docutils literal">FacetReview</span> facts, not whole candidate
|
||||
records or a generic JSON value tree. Historical UUIDs remain historical, including
|
||||
after deletion, and source uses explicit <span class="docutils literal">uuid</span> tokens for them.</p>
|
||||
</section>
|
||||
<section id="error-contract">
|
||||
<h2>Error contract</h2>
|
||||
<p>All errors have <span class="docutils literal"><span class="pre">Content-Type:</span> application/problem+json</span>. Example:</p>
|
||||
|
||||
+124
-6
@@ -124,6 +124,15 @@ bearer token, if any. Non-loopback binding requires a configured token.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/transitions/apply``
|
||||
- Conditionally apply that client-held plan and base_revision.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/semantic-transitions/query``
|
||||
- Read selected traces' typed facts, own drift and linked impacts.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/semantic-transitions/preview``
|
||||
- Prepare typed semantic decisions and source writeback without writing.
|
||||
* - POST
|
||||
- ``/api/v1/projects/{id}/semantic-transitions/apply``
|
||||
- Apply that prepared candidate under its original shared revision/ETag.
|
||||
|
||||
``Content-Type: application/json`` is required for JSON requests.
|
||||
``text/plain; charset=utf-8`` is used for Arcadia source. The service does not
|
||||
@@ -146,8 +155,8 @@ Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.
|
||||
|
||||
Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
``?revision=...`` to model/source export is an error, not historical retrieval.
|
||||
@@ -160,7 +169,7 @@ A source bundle contains ``entry`` and a map of ``files``. For example::
|
||||
{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 5; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}
|
||||
@@ -172,7 +181,7 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.
|
||||
|
||||
@@ -394,7 +403,7 @@ contract in the same transaction when removing a used interface allocation.
|
||||
Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
current exports use schema5/source5. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.
|
||||
|
||||
Validation
|
||||
@@ -410,7 +419,7 @@ or::
|
||||
|
||||
or::
|
||||
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}
|
||||
|
||||
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
@@ -506,6 +515,115 @@ authenticated as an untouched server preview; the same authenticated client
|
||||
can submit ordinary typed commands. The core fingerprint is a change detector,
|
||||
not a security digest. There is no plan token, expiration store or receipt.
|
||||
|
||||
Typed semantic transition review
|
||||
--------------------------------
|
||||
|
||||
The sibling ``semantic-transitions`` routes use the `typed review semantics
|
||||
<language.html#typed-semantic-review-facts>`_ in ``language.rst`` sections 5.1–5.5.
|
||||
The original prose transition routes keep
|
||||
their existing contract. All new routes use JSON and the existing authentication,
|
||||
capacity, worker and error boundaries.
|
||||
|
||||
Query accepts ``{"traces":["trace-uuid"],"references":{},"revision":1}``.
|
||||
``references`` is an optional map of canonical OA UUIDs to SA UUIDs, including
|
||||
historical deleted identities. It never guesses correspondences from names.
|
||||
``revision`` is optional; a mismatch returns 409. Select 1–100 unique existing
|
||||
trace IDs; an empty or duplicate selection returns 400 and a missing trace
|
||||
returns 404. The response includes the current revision and items containing
|
||||
``trace``, ``oa``, ``sa``, typed ``facets``, ``drift`` and ``linked_impacts``.
|
||||
Own and linked drift and their unreviewed flags remain distinct. Query reads one
|
||||
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.
|
||||
|
||||
Preview accepts ``SemanticTransitionRequest``::
|
||||
|
||||
{
|
||||
"transition": {
|
||||
"direction": "OaToSa",
|
||||
"sources": ["40000000-0000-4000-8000-000000000002"],
|
||||
"scope": "SelectedOnly",
|
||||
"mappings": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"target": "40000000-0000-4000-8000-000000000003",
|
||||
"target_kind": null
|
||||
}],
|
||||
"fields": [], "resolutions": [], "destination_diagram": null
|
||||
},
|
||||
"traces": {}, "references": {},
|
||||
"reviews": [{
|
||||
"source": "40000000-0000-4000-8000-000000000002",
|
||||
"resolutions": [
|
||||
{"facet":{"Header":"Name"}, "decision":"TakeSource"},
|
||||
{"facet":{"Data":"Unit"}, "decision":"KeepTarget"}
|
||||
],
|
||||
"reset": []
|
||||
}],
|
||||
"candidates": [], "accepted_impacts": []
|
||||
}
|
||||
|
||||
The example maps an existing OA data type to its traced SA counterpart, takes
|
||||
only its name and acknowledges intentionally different units. Other facets stay
|
||||
unreviewed or retain their earlier drift. Select an explicit trace through the
|
||||
``traces`` map (source UUID to trace UUID) when multiple trace kinds connect the
|
||||
pair. ``reset`` deliberately invalidates exact old selectors after changed
|
||||
correspondence. The optional collections default to empty. Semantic header
|
||||
decisions use typed facets; leave legacy ``transition.fields`` and
|
||||
``transition.resolutions`` empty.
|
||||
|
||||
Facet selectors are closed enums, for example ``{"Data":"IntegerMinimum"}``
|
||||
or ``{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}``.
|
||||
Annotation keys contain the entire path and field; enum literal keys are exact
|
||||
strings. ``KeepTarget``, ``TakeSource`` and ``Candidate`` operate on independently
|
||||
selected facts. Candidate records use existing Element/Relation/ArtifactLink
|
||||
types, must preserve identity/alias, and must equal precisely the result of
|
||||
the selected facets. Constructor review covers its coupled introduced/removed
|
||||
fields. Membership review does not silently acknowledge order; target-only
|
||||
members remain independent. OpenAPI defines every selector and typed payload.
|
||||
|
||||
Preview requires ``If-Match`` and returns ``{"base_revision":1,"plan":{...}}``.
|
||||
The plan contains the original typed request, mapping plan, review facts,
|
||||
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
|
||||
commands, and base/prepared fingerprints. A conflict-free preview also prepares
|
||||
the complete authored source bundle and checks its model equivalence and capacity.
|
||||
No preview changes the model, source, revision or stored bytes.
|
||||
|
||||
If ordinary preparation induces additional cleanup, the plan reports exact typed
|
||||
before/after impacts and a conflict. Review those impacts, place the accepted
|
||||
records in ``accepted_impacts``, and request another preview. Acceptance cannot
|
||||
override a contradictory selected facet. Missing mappings, ambiguous order,
|
||||
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
|
||||
This is the shared core planner and command preparation, with no server-specific
|
||||
semantic mutation engine.
|
||||
|
||||
Apply accepts the entire preview envelope under the same ``If-Match``. It checks
|
||||
``base_revision``, the model fingerprint, unresolved conflicts, the prepared
|
||||
candidate fingerprint, ordinary model validation and coupled source preparation,
|
||||
then atomically stores one accepted model/source revision. The final store check
|
||||
also catches a concurrent edit during preparation. Source-only edits invalidate
|
||||
the preview through the shared ETag even when the model fingerprint is unchanged.
|
||||
Stale ETag/revision returns 412; semantic rejection returns 422 with code
|
||||
``semantic_transition_rejected``. Exact no-op candidates preserve revision.
|
||||
Read and preview again after a successful changed apply before reviewing further.
|
||||
|
||||
A supplied plan remains a client-owned candidate. Fingerprints detect stale or
|
||||
inconsistent proposals; they are not tamper-proof signatures, authentication
|
||||
receipts or proof of original review authorship. Apply preserves preview-generated
|
||||
identities and does not rerun the planner with fresh random IDs. Authenticated
|
||||
clients already have ordinary model/source editing privileges. The server does
|
||||
not persist plans, tokens or review sessions.
|
||||
|
||||
Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
|
||||
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
|
||||
resolutions plus resets across all reviews. The legacy 400 prose-resolution
|
||||
limit and 200-command transaction limit still apply. Query has the same 5,000
|
||||
reference-map limit. Core bounded linked traversal errors remain observable as
|
||||
semantic rejection; no truncated result silently claims complete review.
|
||||
|
||||
Schema/source 5 adds ``TraceBaseline.semantic``. Absence or null is Unreviewed;
|
||||
migrating older prose baselines does not fabricate semantic history. The persisted
|
||||
baseline contains only closed typed ``FacetReview`` facts, not whole candidate
|
||||
records or a generic JSON value tree. Historical UUIDs remain historical, including
|
||||
after deletion, and source uses explicit ``uuid`` tokens for them.
|
||||
|
||||
Error contract
|
||||
--------------
|
||||
|
||||
|
||||
+407
-36
@@ -623,7 +623,7 @@ a { text-decoration: none; }
|
||||
<h1 class="title">Arcadia Studio OA / SA Language and Editing Contract</h1>
|
||||
<dl class="docinfo simple">
|
||||
<dt class="version">Version<span class="colon">:</span></dt>
|
||||
<dd class="version">4</dd>
|
||||
<dd class="version">5</dd>
|
||||
<dt class="status">Status<span class="colon">:</span></dt>
|
||||
<dd class="status">Normative current implementation contract; first-release integration</dd>
|
||||
<dt class="core-implementation">Core implementation<span class="colon">:</span></dt>
|
||||
@@ -637,12 +637,14 @@ a { text-decoration: none; }
|
||||
<h2>1. Scope and conformance</h2>
|
||||
<p>This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1, 2, 3 and 4. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.</p>
|
||||
<p>Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
<p>Schema 5 adds closed typed semantic review facts to trace baselines, including
|
||||
historical references that may no longer exist in the live model. It retains
|
||||
schema 4 bounded reusable data types, typed defaults, exchange mechanisms,
|
||||
qualified values, specialization, interface allocations and associations, and
|
||||
schema 3 typed ports and state views. Explicit older readers preserve meanings
|
||||
and identities without inventing previously reviewed semantic values.
|
||||
This contract does not by itself establish native acceptance or release completion.</p>
|
||||
<p>OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
@@ -661,7 +663,7 @@ Actual verification evidence is separate from this contract.</p>
|
||||
</section>
|
||||
<section id="one-authoritative-model">
|
||||
<h2>2. One authoritative model</h2>
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (4), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (5), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
|
||||
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
|
||||
@@ -1015,12 +1017,12 @@ pickers do not offer it. Resolution to a typed SA contract retains this record
|
||||
without treating it as another active constraint. OA generic ports remain
|
||||
Legacy; OA Interactions stay activity-to-activity without function ports.
|
||||
Deleting a referenced legacy interface clears that optional live reference.</p>
|
||||
<p>A source-3 functional exchange MUST specify both port aliases; the parser does
|
||||
<p>A current-source functional exchange MUST specify both port aliases; the parser does
|
||||
not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.</p>
|
||||
<p>For example:</p>
|
||||
<pre class="literal-block">arcadia 4;
|
||||
<pre class="literal-block">arcadia 5;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
@@ -1042,7 +1044,8 @@ project ports_example "Typed function contract" {
|
||||
sa_port preserved "Unresolved original" {
|
||||
parent @send; contract Legacy;
|
||||
legacy { direction InOut; interface @old_service; };
|
||||
}</pre>
|
||||
}
|
||||
}</pre>
|
||||
<p>An exclusive region has at most one initial state. Missing initial state is
|
||||
a completeness warning. Initial states have no incoming transitions and at
|
||||
most one outgoing transition; their outgoing transition has no trigger/guard.
|
||||
@@ -1394,7 +1397,7 @@ invalid ranges reject. Composition/shared aggregation, multiple inheritance,
|
||||
redefinition, instance literals and arbitrary-precision decimal arithmetic are
|
||||
outside this bounded contract.</p>
|
||||
<p>This complete data contract example is also exercised through the REST API:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
<pre class="code text literal-block"><code>arcadia 5;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
@@ -1457,7 +1460,7 @@ remove or relocate incompatible placements in the same transaction. Context
|
||||
errors use <span class="docutils literal">state_diagram_context</span>; incompatible placements use
|
||||
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
|
||||
<p>This complete source example places one state from an existing machine:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
<pre class="code text literal-block"><code>arcadia 5;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
@@ -1624,9 +1627,11 @@ are invalid.</p>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>A nonempty baseline is <span class="docutils literal">{ oa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> }; sa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> }; }</span>. It records last-reviewed names
|
||||
and descriptions, not a full structural snapshot. Implementations MUST NOT
|
||||
present it as structural synchronization or a lossless inverse transition.
|
||||
<p>A nonempty baseline retains the required prose records <span class="docutils literal">oa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> }; sa { name <span class="pre">"...";</span> description <span class="pre">"...";</span> };</span> and optionally
|
||||
<span class="docutils literal">semantic</span> (default none), defined below. The original prose records and typed
|
||||
semantic facts are distinct review histories. Importing an older prose baseline
|
||||
MUST NOT fabricate semantic acknowledgements. Neither history is a full model
|
||||
snapshot, structural synchronization, or a lossless inverse transition.
|
||||
Explicit counterpart creation, linking, comparison, and reviewed property
|
||||
update may start in either layer. Ordinary edits MUST NOT automatically
|
||||
overwrite the counterpart. Deletion MUST NOT silently cascade across layers.</p>
|
||||
@@ -1638,15 +1643,358 @@ instead of silently creating a different mapping. Required/subtree process and
|
||||
chain transitions include their contextual links, owned contributions and
|
||||
referenced dependencies. New process/chain contribution mappings use Sequence
|
||||
traces; older Scenario-kind occurrence mappings remain accepted. The current
|
||||
name/description baseline does not reconcile later topology changes.</p>
|
||||
<p>Name and description are compared independently against each endpoint's
|
||||
last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
prose baseline alone does not reconcile later topology changes.</p>
|
||||
<p>In the prose-only workflow, name and description are compared independently
|
||||
against each endpoint's last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
different target value. A source-only change proposes an update; different
|
||||
changes on both sides require an explicit decision to take the source, keep
|
||||
the target, or enter a merged value. An explicit decision records both current
|
||||
values as reviewed, even when they differ. A target-only edit is preserved
|
||||
without advancing that field's baseline, so it remains eligible to propagate
|
||||
in the reverse direction. Equal current values can be recorded as converged.</p>
|
||||
<section id="typed-semantic-review-facts">
|
||||
<h3>5.1. Typed semantic review facts</h3>
|
||||
<p><span class="docutils literal">baseline.semantic</span> is either none or <span class="docutils literal">{ facets [facet, <span class="pre">...];</span> }</span>.
|
||||
Each facet is a closed, self-tagged typed record, not a free-form path/value
|
||||
entry. List position does not identify a facet. A scalar fact is exactly
|
||||
<span class="docutils literal">{ Family { Leaf { oa observation; sa observation; }; }; }</span>. Each observation
|
||||
is either <span class="docutils literal">NotApplicable</span> or <span class="docutils literal">{ Value value; }</span>, with the value type fixed
|
||||
by Family/Leaf. Both sides are always canonical OA/SA, even during reverse review.</p>
|
||||
<p><span class="docutils literal">{ Value none; }</span> means an applicable optional field was absent;
|
||||
<span class="docutils literal">NotApplicable</span> means the field's branch or record was not applicable.
|
||||
An absent facet means Unreviewed. These meanings MUST NOT be conflated. Each
|
||||
side is compared with its own last-reviewed value; deliberately unequal values
|
||||
can be reviewed and unchanged. Reviewing one minimum does not acknowledge a
|
||||
maximum, unit, default, header or referenced type definition.</p>
|
||||
<p>Historical facts are independently acknowledged observations. They need not
|
||||
compose one coherent former model: two bounds can have different review times.
|
||||
They MAY outlive the endpoint's former record family, constructor and referenced
|
||||
objects. Historical admission checks their closed payload types, finite values,
|
||||
text, keys and uniqueness; it MUST NOT require a currently existing referenced
|
||||
object or current family applicability. In contrast, a new graphical or planned
|
||||
resolution MUST address the appropriate current record family and have at least
|
||||
one currently applicable side. Both-side NotApplicable facts are invalid persisted
|
||||
acknowledgements. A grouped constructor decision retires such obsolete branch facts.</p>
|
||||
<p>The following table is the exhaustive scalar catalog. <span class="docutils literal">T?</span> denotes an optional
|
||||
T, <span class="docutils literal">T[]</span> a list, and <span class="docutils literal">Id</span> a historical UUID. Existing enum and typed payload
|
||||
definitions in sections 3–4 apply. Every listed leaf wraps <span class="docutils literal">{ oa <span class="pre">...;</span> sa <span class="pre">...;</span> }</span>.</p>
|
||||
<table>
|
||||
<caption>Closed scalar semantic facets</caption>
|
||||
<colgroup>
|
||||
<col style="width: 15.0%" />
|
||||
<col style="width: 85.0%" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Family</p></th>
|
||||
<th class="head"><p>Leaves and observation value types</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Header</p></td>
|
||||
<td><p>Name, Description: string; Parent: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>ExchangeItem</p></td>
|
||||
<td><p>Nature: ExchangeItemKind; Mechanism: ExchangeMechanism; Supertype: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Interface</p></td>
|
||||
<td><p>Supertype: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Data</p></td>
|
||||
<td><p>Constructor: DataConstructor; IntegerMinimum, IntegerMaximum: i64?; RealMinimum, RealMaximum: finite f64?; StringMinimumLength: u32; StringMaximumLength: u32?; RecordSupertype: Id?; Default: DataValue?; Unit: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Field</p></td>
|
||||
<td><p>Type: TypeRef; Minimum: u32; Maximum: u32?; Unit: string; Direction: ParameterDirection; Storage: FieldStorage; Default: DataValue[]?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Port</p></td>
|
||||
<td><p>Constructor: PortConstructor; ComponentKind: ComponentPortKind; Orientation: ComponentPortOrientation; LegacyDirection: PortDirection; LegacyInterface: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Scenario</p></td>
|
||||
<td><p>Viewpoint: ScenarioViewpoint; Context: Id?; Precondition, Postcondition: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Lifeline</p></td>
|
||||
<td><p>Represented: Id?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Occurrence</p></td>
|
||||
<td><p>Constructor: OccurrenceConstructor; Precondition, Postcondition: string; Exchange, Sender, Receiver: Id?; MessageKind: MessageConstructor; TimerExpression: string; ReplyTo, Action, ActionLifeline: Id?; ControlKind: ControlKind; Sequence, Scenario, State, StateLifeline: Id?; FragmentOperator: FragmentOperator; BranchGuard: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Event</p></td>
|
||||
<td><p>Constructor: EventConstructor; TimeKind: TimeEventKind; TimeExpression, ChangeExpression: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>State</p></td>
|
||||
<td><p>Kind: StateKind; Entry, Exit, DoActivity: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Constraint</p></td>
|
||||
<td><p>Expression: string</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Relation</p></td>
|
||||
<td><p>Source, Target: Id; AllocationOrder: u32; Transmission: TransmissionProtocol; Acquisition: AcquisitionProtocol; SourceRole, TargetRole: string; SourceMinimum, TargetMinimum: u32; SourceMaximum, TargetMaximum: u32?; SourceNavigable, TargetNavigable: bool; SourcePort, TargetPort: Id; Oriented: bool; ExtendCondition, SequenceGuard: string; TransitionKind: TransitionKind; Trigger, Guard, Effect: string; StateActionPhase: StateActionKind</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section id="members-rows-order-and-attached-records">
|
||||
<h3>5.2. Members, rows, order, and attached records</h3>
|
||||
<p>The remaining facet forms are closed records. <span class="docutils literal">keys</span> means <span class="docutils literal">{ oa <span class="pre">key-or-none;</span> sa <span class="pre">key-or-none;</span> }</span>; at least one key MUST be present. <span class="docutils literal">review</span> means the same
|
||||
required OA/SA observation pair used above.</p>
|
||||
<table>
|
||||
<caption>Closed keyed and ordered semantic facets</caption>
|
||||
<colgroup>
|
||||
<col style="width: 17.0%" />
|
||||
<col style="width: 45.0%" />
|
||||
<col style="width: 38.0%" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Outer tag</p></th>
|
||||
<th class="head"><p>Required fields</p></th>
|
||||
<th class="head"><p>Key / reviewed value type</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Member</p></td>
|
||||
<td><p>collection; keys; review</p></td>
|
||||
<td><p>Id / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Order</p></td>
|
||||
<td><p>collection; review</p></td>
|
||||
<td><p>No keys / Id[]</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Literal</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Exact case-sensitive string / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Annotation</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p><span class="docutils literal">{ path <span class="pre">Id[];</span> field Id; }</span> / MessageValue?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Binding</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Referenced lifeline Id / LifelineBinding?</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Child</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Direct child Id / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Incident</p></td>
|
||||
<td><p>keys; end; review</p></td>
|
||||
<td><p>Relation Id, end Source or Target / bool</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Artifact</p></td>
|
||||
<td><p>keys; review</p></td>
|
||||
<td><p>Artifact Id / nested artifact facet below</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Member.collection is exactly Steps, FragmentBranches, FunctionInputItems,
|
||||
FunctionOutputItems, SignalItems, ExchangeItems, TransitionTriggers,
|
||||
TransitionEffects or Constrained. Order.collection is exactly Steps or
|
||||
FragmentBranches. Order is independent of membership and MUST contain each
|
||||
surviving member exactly once when applied. Membership Keep does not refresh
|
||||
order history. An automatic insertion requires a unique unchanged mapped anchor;
|
||||
ambiguous insertion or conflicting inferred orders requires an explicit final
|
||||
target order. Independent removals can filter the order to surviving members.</p>
|
||||
<p>Annotation identity is the complete <span class="docutils literal">(path, field)</span> pair. Its MessageValue
|
||||
payload contains <span class="docutils literal">path</span>, <span class="docutils literal">field</span> and <span class="docutils literal">expression</span>; the payload key MUST
|
||||
match its side's selector key. Binding payloads contain <span class="docutils literal">referenced</span> and
|
||||
<span class="docutils literal">local</span>; <span class="docutils literal">referenced</span> MUST match that side's key. Each row is one atomic
|
||||
fact. Changing its key is an explicit old-row removal/new-row addition, not an
|
||||
expression-only edit. Defaults containing repeated DataValues are atomic optional
|
||||
vectors because those values have no stable member identity.</p>
|
||||
<p>Artifact.review is <span class="docutils literal">{ Leaf { oa observation; sa observation; }; }</span> where Leaf
|
||||
is Presence: bool; Name, Description or Uri: string; Owner: Id;
|
||||
Relation: ArtifactRelation; Revision or Fragment: string?. Metadata decisions
|
||||
are authorized on the artifact's attachment in the initial project. An explicit
|
||||
Owner candidate may move a target-only link and record its resulting Owner and
|
||||
other selected metadata. Presence remains relative to the trace owner. Subsequent
|
||||
metadata decisions require its new owner trace; historical facts remain attached
|
||||
to the trace that recorded them. No review fetches artifact content.</p>
|
||||
<p>Duplicate scalar selectors and keyed selectors that occupy the same OA slot or
|
||||
the same SA slot are invalid, even when the other side's key differs. Historical
|
||||
order vectors MUST NOT repeat an Id. Review history has a limit of 16384 facets,
|
||||
128 Ids in an annotation path, 1000000 bytes per string, and 1000000 traversal
|
||||
steps per baseline. Exceeding these implementation work limits produces a
|
||||
diagnostic; truncation MUST NOT be accepted as a complete review.</p>
|
||||
</section>
|
||||
<section id="constructor-groups-and-decisions">
|
||||
<h3>5.3. Constructor groups and decisions</h3>
|
||||
<p>Persisted constructors are discriminator words only:</p>
|
||||
<ul class="simple">
|
||||
<li><p>DataConstructor: Boolean, Integer, Real, String, Enumeration, Record.</p></li>
|
||||
<li><p>PortConstructor: FunctionInput, FunctionOutput, Component, Legacy.</p></li>
|
||||
<li><p>OccurrenceConstructor: Exchange, Action, Control, SequenceReference,
|
||||
ScenarioUse, State, Fragment, Branch.</p></li>
|
||||
<li><p>EventConstructor: Signal, Time, Change.</p></li>
|
||||
<li><p>MessageConstructor: Asynchronous, Call, Reply, Create, Delete, Timer.</p></li>
|
||||
</ul>
|
||||
<p>When a discriminator has no prior fact after explicit resets, both current sides
|
||||
are applicable and equal, and the proposed target discriminator is unchanged,
|
||||
KeepTarget, TakeSource or Candidate acknowledges only that discriminator.
|
||||
It MUST NOT copy a constructor payload, acknowledge unselected descendants or
|
||||
retire branch history. Descendant facets may be independently selected in the
|
||||
same review; their decisions retain their own scopes and final-value audits.
|
||||
Candidate records still MUST NOT change an unselected facet. Missing applicability
|
||||
on either side does not qualify as an equal discriminator acknowledgement.</p>
|
||||
<p>A grouped constructor resolution uses a transient typed target record, then
|
||||
records the discriminator and each acknowledged descendant as separate facts.
|
||||
It MUST NOT persist a second whole constructor payload overlapping those leaves.
|
||||
Data includes its branch fields, literals and coupled default, but excludes Unit.
|
||||
Port includes its contract, Legacy fields and item members. Event includes its
|
||||
branch fields and Signal items. Occurrence includes its variant payload, members,
|
||||
order, annotations and bindings, but excludes outer Precondition/Postcondition.
|
||||
MessageKind includes its discriminator and dependent TimerExpression. State.Kind
|
||||
is its own structural facet even though it changes the element's derived kind.
|
||||
Other dependent changes caused by preparation remain explicit impacts.</p>
|
||||
<p>A grouped constructor Keep requires an actual discriminator proposal or a
|
||||
discriminator change since its prior review. Repeating Keep on an already reviewed
|
||||
unequal pair MUST NOT acknowledge unrelated bound/default drift. A real target switch may
|
||||
reconsider a previously kept difference. When the target already has the proposed
|
||||
constructor, reconciliation MUST preserve target-only members rather than replace
|
||||
its whole list. Removed branch facts participate in agreement as NotApplicable;
|
||||
facts with neither side remaining applicable are retired.</p>
|
||||
<p>Semantic review supports these explicit decisions in either direction:</p>
|
||||
<ul class="simple">
|
||||
<li><p>KeepTarget records the current source and retained target values for only the
|
||||
selected facet, or the explicitly displayed constructor group.</p></li>
|
||||
<li><p>TakeSource maps the selected source value through the frozen correspondence and
|
||||
applies only that facet's delta. Unrelated target edits and target-only collection
|
||||
members, rows, children and artifacts remain intact. Removing membership unlinks
|
||||
the target member; it does not authorize deleting the target semantic object.</p></li>
|
||||
<li><p>Candidate supplies final existing typed Element, Relation or Artifact records.
|
||||
Only selected facets may differ from the result. Identity, alias and layer are
|
||||
protected. New artifact creation requires a complete candidate and review of
|
||||
every introduced metadata facet; removal also reviews the metadata retirement.</p></li>
|
||||
</ul>
|
||||
<p>Headers have explicit semantic Name/Description facets; the original prose-only
|
||||
workflow remains supported with its own baseline. Ordinary source editing can
|
||||
explicitly edit a valid baseline; neither baseline format attests that a human
|
||||
performed a particular review workflow.
|
||||
Decision requests, final candidates and prepared impacts are transient editor/API
|
||||
data. Source persists the resulting ordinary model and typed baseline facts;
|
||||
it does not persist KeepTarget/TakeSource/Candidate as extra facet fields.</p>
|
||||
</section>
|
||||
<section id="correspondence-agreement-and-atomic-preparation">
|
||||
<h3>5.4. Correspondence, agreement, and atomic preparation</h3>
|
||||
<p>Correspondence is an explicit canonical OA-to-SA Id map, including historical
|
||||
deleted IDs. Names and aliases MUST NOT guess correspondence. Reverse application
|
||||
uses its inverse and rejects ambiguity. Multiple trace kinds for one endpoint
|
||||
pair require an explicit trace selection. Changing a keyed pairing requires an
|
||||
explicit reset naming the prior selector; old pairs are never silently replaced.
|
||||
If a previously paired member or branch disappears on one side, retain its pair
|
||||
with the absent/NotApplicable observation, suppressing a redundant one-sided
|
||||
projection. A newly appearing pair still requires reset of old one-sided history.</p>
|
||||
<p>All selected source proposals use one immutable initial project. Resets precede
|
||||
catalog and constructor-scope derivation. Many sources may affect one target only
|
||||
when their desired values agree on every actual canonical target slot, including
|
||||
removals and members introduced by another proposal. Child membership and the
|
||||
actual child's Parent share one slot; incidents share the relation's Source or
|
||||
Target endpoint; artifact Presence and Owner share the actual artifact owner slot.
|
||||
Conflicting desired values MUST reject independently of request order.</p>
|
||||
<p>Materialize the agreed writes once, then audit each desired selected value.
|
||||
Prepare one ordinary complete Command batch and compare its actual project with
|
||||
the explicitly reviewed candidate. Additional cleanup, such as inherited message
|
||||
annotation pruning, is reported as exact object <span class="docutils literal">before</span>/<span class="docutils literal">after</span> typed records
|
||||
(or absence for creation/deletion). Every induced impact requires explicit review;
|
||||
an accepted impact still MUST NOT contradict a selected Keep/Take. Repeat the
|
||||
selected-value audit after preparation. Baseline updates belong to the same atomic
|
||||
transaction and source-preserving writeback as model edits.</p>
|
||||
<p>A plan's base fingerprint detects a stale model; its prepared fingerprint checks
|
||||
candidate consistency. The plan is client-supplied data, not an authenticated
|
||||
receipt or proof of its original decisions. Applying it validates the complete
|
||||
ordinary candidate and preserves generated identities; it does not re-run its
|
||||
request to generate new identities. Hosted clients additionally use the revision
|
||||
preconditions defined in <span class="docutils literal">api.rst</span>. Invalid/stale plans or failed source writeback
|
||||
leave the accepted model, source, baseline, revision and history unchanged.</p>
|
||||
<p>Own drift and linked impacts are separate, with incomplete review explicitly
|
||||
visible. Absent facts MUST NOT produce a falsely complete Unchanged rollup.
|
||||
Shared data/interface/event definitions are reviewed on their own traces; their
|
||||
bounded derived impact walk follows reusable definitions and interface allocations,
|
||||
not arbitrary architectural neighborhoods, and does not copy them into every
|
||||
referrer's baseline. Its limits are 4096 objects per side and 32768 edges; exhaustion
|
||||
is a diagnostic. Successful review remains one undoable transaction. Replanning
|
||||
unchanged, already acknowledged leaf facts is a no-op only while their requests
|
||||
remain applicable. A completed constructor switch or first acknowledgement of an
|
||||
equal discriminator MUST be removed from subsequent requests; repeating it when
|
||||
no switch remains is rejected. After an artifact moves to another owner,
|
||||
metadata review through the old owner's trace is no longer
|
||||
applicable and is rejected. An old plan becomes stale after an actual committed
|
||||
change.</p>
|
||||
</section>
|
||||
<section id="source-examples-and-historical-token-rules">
|
||||
<h3>5.5. Source examples and historical token rules</h3>
|
||||
<p>This valid source records deliberately different constructor/unit observations.
|
||||
Its historical integer minimum is absent; the Real side has no integer branch:</p>
|
||||
<pre class="literal-block">arcadia 5;
|
||||
project reviewed_dimensions "Reviewed dimensions" {
|
||||
oa_data_type oa_dimension "Operational distance" {
|
||||
definition { Integer { minimum 0; maximum 10; }; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "System distance" {
|
||||
definition { Real { minimum 0; maximum 20; }; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension review" {
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description ""; };
|
||||
sa { name "Approved SA name"; description ""; };
|
||||
semantic { facets [
|
||||
{ Data { Constructor { oa { Value Integer; }; sa { Value Real; }; }; }; },
|
||||
{ Data { IntegerMinimum { oa { Value none; }; sa NotApplicable; }; }; },
|
||||
{ Data { Unit { oa { Value "m"; }; sa { Value "ft"; }; }; }; }
|
||||
]; };
|
||||
};
|
||||
}
|
||||
}</pre>
|
||||
<p>Historical Id slots require <span class="docutils literal">uuid <span class="pre">"..."</span></span> even if the object still exists.
|
||||
Only typed reference slots inside <span class="docutils literal">baseline.semantic</span> accept that atom.
|
||||
For example, the following is a valid historical facet value, including a deleted
|
||||
named type; <span class="docutils literal">none</span> and bare enum Boolean retain their different typed meanings:</p>
|
||||
<pre class="literal-block">{ Field { Type {
|
||||
oa { Value { Named uuid "40000000-0000-4000-8000-000000000090"; }; };
|
||||
sa { Value Boolean; };
|
||||
}; }; }</pre>
|
||||
<p>A qualified annotation uses the same historical UUID atoms in its key and payload.
|
||||
This complete facet records one old row and an applicable absent target row:</p>
|
||||
<pre class="literal-block">{ Annotation {
|
||||
keys {
|
||||
oa {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
};
|
||||
sa none;
|
||||
};
|
||||
review {
|
||||
oa { Value {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
expression "latitude + 1";
|
||||
}; };
|
||||
sa { Value none; };
|
||||
};
|
||||
}; }</pre>
|
||||
<p>Historical enums require bare words. Ordinary strings, including UUID-looking
|
||||
units, literal keys and expressions, require quoted strings and remain text
|
||||
during identity remapping. <span class="docutils literal">@alias</span> is invalid anywhere inside a semantic
|
||||
baseline. Live parents, trace endpoints and all other live references still
|
||||
require <span class="docutils literal">@alias</span> and MUST NOT accept the historical UUID atom.</p>
|
||||
<p>The following substitutions in these examples are invalid:</p>
|
||||
<ul class="simple">
|
||||
<li><p><span class="docutils literal">oa "NotApplicable";</span> or <span class="docutils literal">oa { Value "Integer"; };</span> in the Constructor
|
||||
review: enum observations require bare words of the correct typed enum.</p></li>
|
||||
<li><p><span class="docutils literal">Named <span class="pre">"40000000-0000-4000-8000-000000000090";</span></span> or <span class="docutils literal">Named @oa_dimension;</span>:
|
||||
a historical Id requires the explicit UUID atom.</p></li>
|
||||
<li><p><span class="docutils literal">Unit { oa { Value uuid <span class="pre">"40000000-0000-4000-8000-000000000090";</span> }; ... }</span>:
|
||||
a text field cannot hold a reference atom.</p></li>
|
||||
<li><p>Repeating the Unit facet, adding an unknown leaf, or supplying a numeric bound
|
||||
as a quoted string: closed typed admission rejects the whole candidate.</p></li>
|
||||
<li><p>Changing only the annotation payload's field UUID suffix from 092 to 093,
|
||||
leaving the key at 092: the row's key and payload disagree.</p></li>
|
||||
<li><p>Changing the document header to <span class="docutils literal">arcadia 4;</span> while retaining <span class="docutils literal">semantic</span>:
|
||||
the frozen earlier schema does not admit a future baseline shape.</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
<section id="text-grammar-and-resolution">
|
||||
<h2>6. Text grammar and resolution</h2>
|
||||
@@ -1666,7 +2014,7 @@ spellings can be used in alias positions because references begin with <span cla
|
||||
<span class="docutils literal"><span class="pre">element-kind</span></span> and <span class="docutils literal"><span class="pre">relation-kind</span></span> are exactly the corresponding declaration
|
||||
keywords listed above. Typed field
|
||||
sets and enum constructors are closed by sections 3–5.</p>
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "4" ";" project
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "5" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -1678,7 +2026,8 @@ element-body = "{" (field-name value ";" | element)* "}
|
||||
identity = "id" uuid-string
|
||||
fields = "{" (field-name value ";")* "}"
|
||||
value = string | number | "true" | "false" | "none"
|
||||
| enum-word | "@" alias | list | fields
|
||||
| enum-word | "@" alias | historical-id | list | fields
|
||||
historical-id = "uuid" uuid-string
|
||||
list = "[" (value ("," value)*)? "]"</code></pre>
|
||||
<p>The project description defaults to empty and may appear once. There is no
|
||||
semicolon after a declaration or project closing brace. Every field inside a
|
||||
@@ -1696,6 +2045,8 @@ bare words cannot substitute for reference-valued fields. Alias resolution
|
||||
produces UUID references; the alias itself remains stored on its declaration.
|
||||
Changing an alias in source requires updating its uses. Display names never
|
||||
resolve references.</p>
|
||||
<p><span class="docutils literal"><span class="pre">historical-id</span></span> is context-restricted to the typed semantic baseline reference
|
||||
slots in section 5.5; it is not an alternative spelling of a live reference.</p>
|
||||
<p>Unknown fields, duplicate fields, incompatible constructor payloads, unsupported
|
||||
enum spellings, duplicate aliases/identities, and structural validation errors
|
||||
reject the entire candidate. No declaration silently replaces another.
|
||||
@@ -1707,12 +2058,12 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
|
||||
<h3>6.1. Modular source projects</h3>
|
||||
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
|
||||
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source4 project header above. A project entry
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source5 project header above. A project entry
|
||||
MAY also be <span class="docutils literal">include string ";"</span>. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example:</p>
|
||||
<pre class="literal-block">arcadia 4;
|
||||
<pre class="literal-block">arcadia 5;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -1770,12 +2121,17 @@ the source index. Columns remain one-based Unicode scalars within the real file;
|
||||
spans never refer to concatenated or synthetic source.</p>
|
||||
<p>Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
continue through explicit migration. Modular projects accept source3, source4 and
|
||||
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.</p>
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
An authored source4 bundle upgrades through the strict prose-only baseline
|
||||
adapter; after any missing UUID stabilization, the version token alone changes
|
||||
to 5. Existing prose, comments, includes and nested ownership remain intact.
|
||||
Source5 semantic facet comments follow their closed selector identity through
|
||||
row reordering and reidentification; UUID-looking text does not act as an Id.</p>
|
||||
</section>
|
||||
<section id="nested-element-ownership">
|
||||
<h3>6.2. Nested element ownership</h3>
|
||||
@@ -1979,9 +2335,17 @@ explicitly before Apply succeeds.</p>
|
||||
<h2>9. Persistence and explicit migration</h2>
|
||||
<p>Loading detects JSON from its first non-whitespace <span class="docutils literal">{</span>; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an <span class="docutils literal">.arcadia</span> suffix uses
|
||||
the accepted V4 source bundle (<span class="docutils literal">arcadia 4</span>); other suffixes use V4 JSON. Both forms preserve the same
|
||||
the accepted V5 source bundle (<span class="docutils literal">arcadia 5</span>); other suffixes use V5 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.</p>
|
||||
<p>The explicit schema-4 reader retains the exact old Project/Trace/TraceBaseline
|
||||
envelope with closed fields. It reuses unchanged typed elements, relations,
|
||||
diagrams and artifacts. Version4 prose baselines migrate with semantic none;
|
||||
their names/descriptions remain exact even when live endpoint values differ.
|
||||
The adapter MUST NOT infer semantic review from live values, and version3/4
|
||||
inputs MUST reject semantic baseline fields. The migration report retains the
|
||||
original from_schema and current to_schema 5. Reopening a current5 save produces
|
||||
no migration report. The real V4 reviewed-prose fixtures remain compatibility input.</p>
|
||||
<p>The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
@@ -2001,9 +2365,9 @@ reuse, retain the old annotation with a located warning rather than guessing.</p
|
||||
description), <span class="docutils literal">generated_interface_allocations</span> (id, source, target, alias,
|
||||
order) and <span class="docutils literal">unresolved_message_values</span> (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.</p>
|
||||
through V2, then the typed-port V3 adapter, shared V4 data upgrade and V5 review
|
||||
adapter. Original V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 5; reading/migrating alone never saves or changes revisions.</p>
|
||||
<p>Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
@@ -2027,7 +2391,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.</p>
|
||||
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.</p>
|
||||
<p>The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||
@@ -2044,13 +2408,13 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
schema 5. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.</p>
|
||||
<p>Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
construction. Both the old V2 contract and the resulting V5 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4 stages.</p>
|
||||
model and then uses these same V2-to-3-to-4-to-5 stages.</p>
|
||||
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
not truncate a previous file. External concurrent-writer conflict resolution
|
||||
@@ -2066,20 +2430,20 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
input therefore yields the same V5 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.</p>
|
||||
<p>V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V4 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
V5 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
</section>
|
||||
<section id="complete-authoring-example">
|
||||
<h2>10. Complete authoring example</h2>
|
||||
<p>This document intentionally omits IDs for new authoring. Apply and Save write
|
||||
the assigned UUIDs explicitly.</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
<pre class="code text literal-block"><code>arcadia 5;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
@@ -2116,6 +2480,13 @@ stable aliases, strict syntax/reference failures, Unicode source spans,
|
||||
deterministic V1 migration, occurrence identity, coordinate bits, persistence,
|
||||
and model-history integration. Core validation and session tests cover domain
|
||||
mutation rules; native UI and API verification are separate evidence.</p>
|
||||
<p><span class="docutils literal">cargo test <span class="pre">-p</span> <span class="pre">oa-core</span> <span class="pre">--test</span> r3_semantic <span class="pre">--test</span> r3_source <span class="pre">--test</span> r3_migration <span class="pre">--locked</span></span>
|
||||
checks the closed facet catalog, partial decisions, constructor scopes, canonical
|
||||
agreement, target-only preservation, historic references, exact prepared impacts,
|
||||
source/JSON parity, comment ownership and explicit original1–4 migration. The
|
||||
source suite includes actual included-file save/reopen and prepared review
|
||||
writeback. These checks establish core/source behavior; they do not establish
|
||||
graphical or hosted API acceptance by themselves.</p>
|
||||
<p>The research record in <span class="docutils literal"><span class="pre">docs/research-v2-semantics.md</span></span> distinguishes method
|
||||
requirements from Studio design choices and links to original UI evidence.
|
||||
The following sources inform this language without extending its scope:</p>
|
||||
|
||||
+399
-35
@@ -1,7 +1,7 @@
|
||||
Arcadia Studio OA / SA Language and Editing Contract
|
||||
====================================================
|
||||
|
||||
:Version: 4
|
||||
:Version: 5
|
||||
:Status: Normative current implementation contract; first-release integration
|
||||
:Core implementation: ``crates/oa-core``
|
||||
:Text suffix: ``.arcadia``
|
||||
@@ -11,13 +11,15 @@ Arcadia Studio OA / SA Language and Editing Contract
|
||||
|
||||
This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1, 2, 3 and 4. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.
|
||||
|
||||
Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
Schema 5 adds closed typed semantic review facts to trace baselines, including
|
||||
historical references that may no longer exist in the live model. It retains
|
||||
schema 4 bounded reusable data types, typed defaults, exchange mechanisms,
|
||||
qualified values, specialization, interface allocations and associations, and
|
||||
schema 3 typed ports and state views. Explicit older readers preserve meanings
|
||||
and identities without inventing previously reviewed semantic values.
|
||||
This contract does not by itself establish native acceptance or release completion.
|
||||
|
||||
OA describes stakeholders, operational work and needs. SA describes the
|
||||
@@ -39,7 +41,7 @@ Actual verification evidence is separate from this contract.
|
||||
2. One authoritative model
|
||||
--------------------------
|
||||
|
||||
A Project stores ``schema_version`` (4), ``id``, ``alias``, ``name``,
|
||||
A Project stores ``schema_version`` (5), ``id``, ``alias``, ``name``,
|
||||
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
|
||||
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.
|
||||
@@ -347,14 +349,14 @@ without treating it as another active constraint. OA generic ports remain
|
||||
Legacy; OA Interactions stay activity-to-activity without function ports.
|
||||
Deleting a referenced legacy interface clears that optional live reference.
|
||||
|
||||
A source-3 functional exchange MUST specify both port aliases; the parser does
|
||||
A current-source functional exchange MUST specify both port aliases; the parser does
|
||||
not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.
|
||||
|
||||
For example::
|
||||
|
||||
arcadia 4;
|
||||
arcadia 5;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
@@ -377,6 +379,7 @@ For example::
|
||||
parent @send; contract Legacy;
|
||||
legacy { direction InOut; interface @old_service; };
|
||||
}
|
||||
}
|
||||
|
||||
An exclusive region has at most one initial state. Missing initial state is
|
||||
a completeness warning. Initial states have no incoming transitions and at
|
||||
@@ -736,7 +739,7 @@ This complete data contract example is also exercised through the REST API:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
arcadia 5;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
@@ -805,7 +808,7 @@ This complete source example places one state from an existing machine:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
arcadia 5;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
@@ -975,10 +978,12 @@ are invalid.
|
||||
* - Manual
|
||||
- Engineer-defined relationship between OA/SA elements or relations
|
||||
|
||||
A nonempty baseline is ``{ oa { name "..."; description "..."; };
|
||||
sa { name "..."; description "..."; }; }``. It records last-reviewed names
|
||||
and descriptions, not a full structural snapshot. Implementations MUST NOT
|
||||
present it as structural synchronization or a lossless inverse transition.
|
||||
A nonempty baseline retains the required prose records ``oa { name "...";
|
||||
description "..."; }; sa { name "..."; description "..."; };`` and optionally
|
||||
``semantic`` (default none), defined below. The original prose records and typed
|
||||
semantic facts are distinct review histories. Importing an older prose baseline
|
||||
MUST NOT fabricate semantic acknowledgements. Neither history is a full model
|
||||
snapshot, structural synchronization, or a lossless inverse transition.
|
||||
Explicit counterpart creation, linking, comparison, and reviewed property
|
||||
update may start in either layer. Ordinary edits MUST NOT automatically
|
||||
overwrite the counterpart. Deletion MUST NOT silently cascade across layers.
|
||||
@@ -991,10 +996,10 @@ instead of silently creating a different mapping. Required/subtree process and
|
||||
chain transitions include their contextual links, owned contributions and
|
||||
referenced dependencies. New process/chain contribution mappings use Sequence
|
||||
traces; older Scenario-kind occurrence mappings remain accepted. The current
|
||||
name/description baseline does not reconcile later topology changes.
|
||||
prose baseline alone does not reconcile later topology changes.
|
||||
|
||||
Name and description are compared independently against each endpoint's
|
||||
last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
In the prose-only workflow, name and description are compared independently
|
||||
against each endpoint's last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
|
||||
different target value. A source-only change proposes an update; different
|
||||
changes on both sides require an explicit decision to take the source, keep
|
||||
the target, or enter a merged value. An explicit decision records both current
|
||||
@@ -1002,6 +1007,339 @@ values as reviewed, even when they differ. A target-only edit is preserved
|
||||
without advancing that field's baseline, so it remains eligible to propagate
|
||||
in the reverse direction. Equal current values can be recorded as converged.
|
||||
|
||||
5.1. Typed semantic review facts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``baseline.semantic`` is either none or ``{ facets [facet, ...]; }``.
|
||||
Each facet is a closed, self-tagged typed record, not a free-form path/value
|
||||
entry. List position does not identify a facet. A scalar fact is exactly
|
||||
``{ Family { Leaf { oa observation; sa observation; }; }; }``. Each observation
|
||||
is either ``NotApplicable`` or ``{ Value value; }``, with the value type fixed
|
||||
by Family/Leaf. Both sides are always canonical OA/SA, even during reverse review.
|
||||
|
||||
``{ Value none; }`` means an applicable optional field was absent;
|
||||
``NotApplicable`` means the field's branch or record was not applicable.
|
||||
An absent facet means Unreviewed. These meanings MUST NOT be conflated. Each
|
||||
side is compared with its own last-reviewed value; deliberately unequal values
|
||||
can be reviewed and unchanged. Reviewing one minimum does not acknowledge a
|
||||
maximum, unit, default, header or referenced type definition.
|
||||
|
||||
Historical facts are independently acknowledged observations. They need not
|
||||
compose one coherent former model: two bounds can have different review times.
|
||||
They MAY outlive the endpoint's former record family, constructor and referenced
|
||||
objects. Historical admission checks their closed payload types, finite values,
|
||||
text, keys and uniqueness; it MUST NOT require a currently existing referenced
|
||||
object or current family applicability. In contrast, a new graphical or planned
|
||||
resolution MUST address the appropriate current record family and have at least
|
||||
one currently applicable side. Both-side NotApplicable facts are invalid persisted
|
||||
acknowledgements. A grouped constructor decision retires such obsolete branch facts.
|
||||
|
||||
The following table is the exhaustive scalar catalog. ``T?`` denotes an optional
|
||||
T, ``T[]`` a list, and ``Id`` a historical UUID. Existing enum and typed payload
|
||||
definitions in sections 3–4 apply. Every listed leaf wraps ``{ oa ...; sa ...; }``.
|
||||
|
||||
.. list-table:: Closed scalar semantic facets
|
||||
:header-rows: 1
|
||||
:widths: 15 85
|
||||
|
||||
* - Family
|
||||
- Leaves and observation value types
|
||||
* - Header
|
||||
- Name, Description: string; Parent: Id?
|
||||
* - ExchangeItem
|
||||
- Nature: ExchangeItemKind; Mechanism: ExchangeMechanism; Supertype: Id?
|
||||
* - Interface
|
||||
- Supertype: Id?
|
||||
* - Data
|
||||
- Constructor: DataConstructor; IntegerMinimum, IntegerMaximum: i64?; RealMinimum, RealMaximum: finite f64?; StringMinimumLength: u32; StringMaximumLength: u32?; RecordSupertype: Id?; Default: DataValue?; Unit: string
|
||||
* - Field
|
||||
- Type: TypeRef; Minimum: u32; Maximum: u32?; Unit: string; Direction: ParameterDirection; Storage: FieldStorage; Default: DataValue[]?
|
||||
* - Port
|
||||
- Constructor: PortConstructor; ComponentKind: ComponentPortKind; Orientation: ComponentPortOrientation; LegacyDirection: PortDirection; LegacyInterface: Id?
|
||||
* - Scenario
|
||||
- Viewpoint: ScenarioViewpoint; Context: Id?; Precondition, Postcondition: string
|
||||
* - Lifeline
|
||||
- Represented: Id?
|
||||
* - Occurrence
|
||||
- Constructor: OccurrenceConstructor; Precondition, Postcondition: string; Exchange, Sender, Receiver: Id?; MessageKind: MessageConstructor; TimerExpression: string; ReplyTo, Action, ActionLifeline: Id?; ControlKind: ControlKind; Sequence, Scenario, State, StateLifeline: Id?; FragmentOperator: FragmentOperator; BranchGuard: string
|
||||
* - Event
|
||||
- Constructor: EventConstructor; TimeKind: TimeEventKind; TimeExpression, ChangeExpression: string
|
||||
* - State
|
||||
- Kind: StateKind; Entry, Exit, DoActivity: string
|
||||
* - Constraint
|
||||
- Expression: string
|
||||
* - Relation
|
||||
- Source, Target: Id; AllocationOrder: u32; Transmission: TransmissionProtocol; Acquisition: AcquisitionProtocol; SourceRole, TargetRole: string; SourceMinimum, TargetMinimum: u32; SourceMaximum, TargetMaximum: u32?; SourceNavigable, TargetNavigable: bool; SourcePort, TargetPort: Id; Oriented: bool; ExtendCondition, SequenceGuard: string; TransitionKind: TransitionKind; Trigger, Guard, Effect: string; StateActionPhase: StateActionKind
|
||||
|
||||
5.2. Members, rows, order, and attached records
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The remaining facet forms are closed records. ``keys`` means ``{ oa key-or-none;
|
||||
sa key-or-none; }``; at least one key MUST be present. ``review`` means the same
|
||||
required OA/SA observation pair used above.
|
||||
|
||||
.. list-table:: Closed keyed and ordered semantic facets
|
||||
:header-rows: 1
|
||||
:widths: 17 45 38
|
||||
|
||||
* - Outer tag
|
||||
- Required fields
|
||||
- Key / reviewed value type
|
||||
* - Member
|
||||
- collection; keys; review
|
||||
- Id / bool
|
||||
* - Order
|
||||
- collection; review
|
||||
- No keys / Id[]
|
||||
* - Literal
|
||||
- keys; review
|
||||
- Exact case-sensitive string / bool
|
||||
* - Annotation
|
||||
- keys; review
|
||||
- ``{ path Id[]; field Id; }`` / MessageValue?
|
||||
* - Binding
|
||||
- keys; review
|
||||
- Referenced lifeline Id / LifelineBinding?
|
||||
* - Child
|
||||
- keys; review
|
||||
- Direct child Id / bool
|
||||
* - Incident
|
||||
- keys; end; review
|
||||
- Relation Id, end Source or Target / bool
|
||||
* - Artifact
|
||||
- keys; review
|
||||
- Artifact Id / nested artifact facet below
|
||||
|
||||
Member.collection is exactly Steps, FragmentBranches, FunctionInputItems,
|
||||
FunctionOutputItems, SignalItems, ExchangeItems, TransitionTriggers,
|
||||
TransitionEffects or Constrained. Order.collection is exactly Steps or
|
||||
FragmentBranches. Order is independent of membership and MUST contain each
|
||||
surviving member exactly once when applied. Membership Keep does not refresh
|
||||
order history. An automatic insertion requires a unique unchanged mapped anchor;
|
||||
ambiguous insertion or conflicting inferred orders requires an explicit final
|
||||
target order. Independent removals can filter the order to surviving members.
|
||||
|
||||
Annotation identity is the complete ``(path, field)`` pair. Its MessageValue
|
||||
payload contains ``path``, ``field`` and ``expression``; the payload key MUST
|
||||
match its side's selector key. Binding payloads contain ``referenced`` and
|
||||
``local``; ``referenced`` MUST match that side's key. Each row is one atomic
|
||||
fact. Changing its key is an explicit old-row removal/new-row addition, not an
|
||||
expression-only edit. Defaults containing repeated DataValues are atomic optional
|
||||
vectors because those values have no stable member identity.
|
||||
|
||||
Artifact.review is ``{ Leaf { oa observation; sa observation; }; }`` where Leaf
|
||||
is Presence: bool; Name, Description or Uri: string; Owner: Id;
|
||||
Relation: ArtifactRelation; Revision or Fragment: string?. Metadata decisions
|
||||
are authorized on the artifact's attachment in the initial project. An explicit
|
||||
Owner candidate may move a target-only link and record its resulting Owner and
|
||||
other selected metadata. Presence remains relative to the trace owner. Subsequent
|
||||
metadata decisions require its new owner trace; historical facts remain attached
|
||||
to the trace that recorded them. No review fetches artifact content.
|
||||
|
||||
Duplicate scalar selectors and keyed selectors that occupy the same OA slot or
|
||||
the same SA slot are invalid, even when the other side's key differs. Historical
|
||||
order vectors MUST NOT repeat an Id. Review history has a limit of 16384 facets,
|
||||
128 Ids in an annotation path, 1000000 bytes per string, and 1000000 traversal
|
||||
steps per baseline. Exceeding these implementation work limits produces a
|
||||
diagnostic; truncation MUST NOT be accepted as a complete review.
|
||||
|
||||
5.3. Constructor groups and decisions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Persisted constructors are discriminator words only:
|
||||
|
||||
* DataConstructor: Boolean, Integer, Real, String, Enumeration, Record.
|
||||
* PortConstructor: FunctionInput, FunctionOutput, Component, Legacy.
|
||||
* OccurrenceConstructor: Exchange, Action, Control, SequenceReference,
|
||||
ScenarioUse, State, Fragment, Branch.
|
||||
* EventConstructor: Signal, Time, Change.
|
||||
* MessageConstructor: Asynchronous, Call, Reply, Create, Delete, Timer.
|
||||
|
||||
When a discriminator has no prior fact after explicit resets, both current sides
|
||||
are applicable and equal, and the proposed target discriminator is unchanged,
|
||||
KeepTarget, TakeSource or Candidate acknowledges only that discriminator.
|
||||
It MUST NOT copy a constructor payload, acknowledge unselected descendants or
|
||||
retire branch history. Descendant facets may be independently selected in the
|
||||
same review; their decisions retain their own scopes and final-value audits.
|
||||
Candidate records still MUST NOT change an unselected facet. Missing applicability
|
||||
on either side does not qualify as an equal discriminator acknowledgement.
|
||||
|
||||
A grouped constructor resolution uses a transient typed target record, then
|
||||
records the discriminator and each acknowledged descendant as separate facts.
|
||||
It MUST NOT persist a second whole constructor payload overlapping those leaves.
|
||||
Data includes its branch fields, literals and coupled default, but excludes Unit.
|
||||
Port includes its contract, Legacy fields and item members. Event includes its
|
||||
branch fields and Signal items. Occurrence includes its variant payload, members,
|
||||
order, annotations and bindings, but excludes outer Precondition/Postcondition.
|
||||
MessageKind includes its discriminator and dependent TimerExpression. State.Kind
|
||||
is its own structural facet even though it changes the element's derived kind.
|
||||
Other dependent changes caused by preparation remain explicit impacts.
|
||||
|
||||
A grouped constructor Keep requires an actual discriminator proposal or a
|
||||
discriminator change since its prior review. Repeating Keep on an already reviewed
|
||||
unequal pair MUST NOT acknowledge unrelated bound/default drift. A real target switch may
|
||||
reconsider a previously kept difference. When the target already has the proposed
|
||||
constructor, reconciliation MUST preserve target-only members rather than replace
|
||||
its whole list. Removed branch facts participate in agreement as NotApplicable;
|
||||
facts with neither side remaining applicable are retired.
|
||||
|
||||
Semantic review supports these explicit decisions in either direction:
|
||||
|
||||
* KeepTarget records the current source and retained target values for only the
|
||||
selected facet, or the explicitly displayed constructor group.
|
||||
* TakeSource maps the selected source value through the frozen correspondence and
|
||||
applies only that facet's delta. Unrelated target edits and target-only collection
|
||||
members, rows, children and artifacts remain intact. Removing membership unlinks
|
||||
the target member; it does not authorize deleting the target semantic object.
|
||||
* Candidate supplies final existing typed Element, Relation or Artifact records.
|
||||
Only selected facets may differ from the result. Identity, alias and layer are
|
||||
protected. New artifact creation requires a complete candidate and review of
|
||||
every introduced metadata facet; removal also reviews the metadata retirement.
|
||||
|
||||
Headers have explicit semantic Name/Description facets; the original prose-only
|
||||
workflow remains supported with its own baseline. Ordinary source editing can
|
||||
explicitly edit a valid baseline; neither baseline format attests that a human
|
||||
performed a particular review workflow.
|
||||
Decision requests, final candidates and prepared impacts are transient editor/API
|
||||
data. Source persists the resulting ordinary model and typed baseline facts;
|
||||
it does not persist KeepTarget/TakeSource/Candidate as extra facet fields.
|
||||
|
||||
5.4. Correspondence, agreement, and atomic preparation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Correspondence is an explicit canonical OA-to-SA Id map, including historical
|
||||
deleted IDs. Names and aliases MUST NOT guess correspondence. Reverse application
|
||||
uses its inverse and rejects ambiguity. Multiple trace kinds for one endpoint
|
||||
pair require an explicit trace selection. Changing a keyed pairing requires an
|
||||
explicit reset naming the prior selector; old pairs are never silently replaced.
|
||||
If a previously paired member or branch disappears on one side, retain its pair
|
||||
with the absent/NotApplicable observation, suppressing a redundant one-sided
|
||||
projection. A newly appearing pair still requires reset of old one-sided history.
|
||||
|
||||
All selected source proposals use one immutable initial project. Resets precede
|
||||
catalog and constructor-scope derivation. Many sources may affect one target only
|
||||
when their desired values agree on every actual canonical target slot, including
|
||||
removals and members introduced by another proposal. Child membership and the
|
||||
actual child's Parent share one slot; incidents share the relation's Source or
|
||||
Target endpoint; artifact Presence and Owner share the actual artifact owner slot.
|
||||
Conflicting desired values MUST reject independently of request order.
|
||||
|
||||
Materialize the agreed writes once, then audit each desired selected value.
|
||||
Prepare one ordinary complete Command batch and compare its actual project with
|
||||
the explicitly reviewed candidate. Additional cleanup, such as inherited message
|
||||
annotation pruning, is reported as exact object ``before``/``after`` typed records
|
||||
(or absence for creation/deletion). Every induced impact requires explicit review;
|
||||
an accepted impact still MUST NOT contradict a selected Keep/Take. Repeat the
|
||||
selected-value audit after preparation. Baseline updates belong to the same atomic
|
||||
transaction and source-preserving writeback as model edits.
|
||||
|
||||
A plan's base fingerprint detects a stale model; its prepared fingerprint checks
|
||||
candidate consistency. The plan is client-supplied data, not an authenticated
|
||||
receipt or proof of its original decisions. Applying it validates the complete
|
||||
ordinary candidate and preserves generated identities; it does not re-run its
|
||||
request to generate new identities. Hosted clients additionally use the revision
|
||||
preconditions defined in ``api.rst``. Invalid/stale plans or failed source writeback
|
||||
leave the accepted model, source, baseline, revision and history unchanged.
|
||||
|
||||
Own drift and linked impacts are separate, with incomplete review explicitly
|
||||
visible. Absent facts MUST NOT produce a falsely complete Unchanged rollup.
|
||||
Shared data/interface/event definitions are reviewed on their own traces; their
|
||||
bounded derived impact walk follows reusable definitions and interface allocations,
|
||||
not arbitrary architectural neighborhoods, and does not copy them into every
|
||||
referrer's baseline. Its limits are 4096 objects per side and 32768 edges; exhaustion
|
||||
is a diagnostic. Successful review remains one undoable transaction. Replanning
|
||||
unchanged, already acknowledged leaf facts is a no-op only while their requests
|
||||
remain applicable. A completed constructor switch or first acknowledgement of an
|
||||
equal discriminator MUST be removed from subsequent requests; repeating it when
|
||||
no switch remains is rejected. After an artifact moves to another owner,
|
||||
metadata review through the old owner's trace is no longer
|
||||
applicable and is rejected. An old plan becomes stale after an actual committed
|
||||
change.
|
||||
|
||||
5.5. Source examples and historical token rules
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This valid source records deliberately different constructor/unit observations.
|
||||
Its historical integer minimum is absent; the Real side has no integer branch::
|
||||
|
||||
arcadia 5;
|
||||
project reviewed_dimensions "Reviewed dimensions" {
|
||||
oa_data_type oa_dimension "Operational distance" {
|
||||
definition { Integer { minimum 0; maximum 10; }; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "System distance" {
|
||||
definition { Real { minimum 0; maximum 20; }; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension review" {
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description ""; };
|
||||
sa { name "Approved SA name"; description ""; };
|
||||
semantic { facets [
|
||||
{ Data { Constructor { oa { Value Integer; }; sa { Value Real; }; }; }; },
|
||||
{ Data { IntegerMinimum { oa { Value none; }; sa NotApplicable; }; }; },
|
||||
{ Data { Unit { oa { Value "m"; }; sa { Value "ft"; }; }; }; }
|
||||
]; };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Historical Id slots require ``uuid "..."`` even if the object still exists.
|
||||
Only typed reference slots inside ``baseline.semantic`` accept that atom.
|
||||
For example, the following is a valid historical facet value, including a deleted
|
||||
named type; ``none`` and bare enum Boolean retain their different typed meanings::
|
||||
|
||||
{ Field { Type {
|
||||
oa { Value { Named uuid "40000000-0000-4000-8000-000000000090"; }; };
|
||||
sa { Value Boolean; };
|
||||
}; }; }
|
||||
|
||||
A qualified annotation uses the same historical UUID atoms in its key and payload.
|
||||
This complete facet records one old row and an applicable absent target row::
|
||||
|
||||
{ Annotation {
|
||||
keys {
|
||||
oa {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
};
|
||||
sa none;
|
||||
};
|
||||
review {
|
||||
oa { Value {
|
||||
path [uuid "40000000-0000-4000-8000-000000000091"];
|
||||
field uuid "40000000-0000-4000-8000-000000000092";
|
||||
expression "latitude + 1";
|
||||
}; };
|
||||
sa { Value none; };
|
||||
};
|
||||
}; }
|
||||
|
||||
Historical enums require bare words. Ordinary strings, including UUID-looking
|
||||
units, literal keys and expressions, require quoted strings and remain text
|
||||
during identity remapping. ``@alias`` is invalid anywhere inside a semantic
|
||||
baseline. Live parents, trace endpoints and all other live references still
|
||||
require ``@alias`` and MUST NOT accept the historical UUID atom.
|
||||
|
||||
The following substitutions in these examples are invalid:
|
||||
|
||||
* ``oa "NotApplicable";`` or ``oa { Value "Integer"; };`` in the Constructor
|
||||
review: enum observations require bare words of the correct typed enum.
|
||||
* ``Named "40000000-0000-4000-8000-000000000090";`` or ``Named @oa_dimension;``:
|
||||
a historical Id requires the explicit UUID atom.
|
||||
* ``Unit { oa { Value uuid "40000000-0000-4000-8000-000000000090"; }; ... }``:
|
||||
a text field cannot hold a reference atom.
|
||||
* Repeating the Unit facet, adding an unknown leaf, or supplying a numeric bound
|
||||
as a quoted string: closed typed admission rejects the whole candidate.
|
||||
* Changing only the annotation payload's field UUID suffix from 092 to 093,
|
||||
leaving the key at 092: the row's key and payload disagree.
|
||||
* Changing the document header to ``arcadia 4;`` while retaining ``semantic``:
|
||||
the frozen earlier schema does not admit a future baseline shape.
|
||||
|
||||
6. Text grammar and resolution
|
||||
------------------------------
|
||||
|
||||
@@ -1026,7 +1364,7 @@ sets and enum constructors are closed by sections 3–5.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
document = "arcadia" "4" ";" project
|
||||
document = "arcadia" "5" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -1038,7 +1376,8 @@ sets and enum constructors are closed by sections 3–5.
|
||||
identity = "id" uuid-string
|
||||
fields = "{" (field-name value ";")* "}"
|
||||
value = string | number | "true" | "false" | "none"
|
||||
| enum-word | "@" alias | list | fields
|
||||
| enum-word | "@" alias | historical-id | list | fields
|
||||
historical-id = "uuid" uuid-string
|
||||
list = "[" (value ("," value)*)? "]"
|
||||
|
||||
The project description defaults to empty and may appear once. There is no
|
||||
@@ -1060,6 +1399,9 @@ produces UUID references; the alias itself remains stored on its declaration.
|
||||
Changing an alias in source requires updating its uses. Display names never
|
||||
resolve references.
|
||||
|
||||
``historical-id`` is context-restricted to the typed semantic baseline reference
|
||||
slots in section 5.5; it is not an alternative spelling of a live reference.
|
||||
|
||||
Unknown fields, duplicate fields, incompatible constructor payloads, unsupported
|
||||
enum spellings, duplicate aliases/identities, and structural validation errors
|
||||
reject the entire candidate. No declaration silently replaces another.
|
||||
@@ -1073,13 +1415,13 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
|
||||
|
||||
The pure ``source_project`` core API compiles a virtual source bundle, containing
|
||||
``entry`` and a ``files`` map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level ``.arcadia`` file with the source4 project header above. A project entry
|
||||
root-level ``.arcadia`` file with the source5 project header above. A project entry
|
||||
MAY also be ``include string ";"``. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example::
|
||||
|
||||
arcadia 4;
|
||||
arcadia 5;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -1145,12 +1487,17 @@ spans never refer to concatenated or synthetic source.
|
||||
|
||||
Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
continue through explicit migration. Modular projects accept source3, source4 and
|
||||
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
An authored source4 bundle upgrades through the strict prose-only baseline
|
||||
adapter; after any missing UUID stabilization, the version token alone changes
|
||||
to 5. Existing prose, comments, includes and nested ownership remain intact.
|
||||
Source5 semantic facet comments follow their closed selector identity through
|
||||
row reordering and reidentification; UUID-looking text does not act as an Id.
|
||||
|
||||
6.2. Nested element ownership
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -1382,10 +1729,19 @@ explicitly before Apply succeeds.
|
||||
|
||||
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
||||
the accepted V4 source bundle (``arcadia 4``); other suffixes use V4 JSON. Both forms preserve the same
|
||||
the accepted V5 source bundle (``arcadia 5``); other suffixes use V5 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.
|
||||
|
||||
The explicit schema-4 reader retains the exact old Project/Trace/TraceBaseline
|
||||
envelope with closed fields. It reuses unchanged typed elements, relations,
|
||||
diagrams and artifacts. Version4 prose baselines migrate with semantic none;
|
||||
their names/descriptions remain exact even when live endpoint values differ.
|
||||
The adapter MUST NOT infer semantic review from live values, and version3/4
|
||||
inputs MUST reject semantic baseline fields. The migration report retains the
|
||||
original from_schema and current to_schema 5. Reopening a current5 save produces
|
||||
no migration report. The real V4 reviewed-prose fixtures remain compatibility input.
|
||||
|
||||
The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
@@ -1407,9 +1763,9 @@ Migration reports additionally contain ``semantic_defaults`` (object, field,
|
||||
description), ``generated_interface_allocations`` (id, source, target, alias,
|
||||
order) and ``unresolved_message_values`` (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.
|
||||
through V2, then the typed-port V3 adapter, shared V4 data upgrade and V5 review
|
||||
adapter. Original V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 5; reading/migrating alone never saves or changes revisions.
|
||||
|
||||
Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
@@ -1435,7 +1791,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.
|
||||
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.
|
||||
|
||||
The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
@@ -1454,14 +1810,14 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
schema 5. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.
|
||||
|
||||
Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
construction. Both the old V2 contract and the resulting V5 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4 stages.
|
||||
model and then uses these same V2-to-3-to-4-to-5 stages.
|
||||
|
||||
Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
@@ -1480,7 +1836,7 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
input therefore yields the same V5 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.
|
||||
|
||||
V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
@@ -1488,7 +1844,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V4 projects. Future versions remain errors until a separate migration exists.
|
||||
V5 projects. Future versions remain errors until a separate migration exists.
|
||||
|
||||
10. Complete authoring example
|
||||
------------------------------
|
||||
@@ -1498,7 +1854,7 @@ the assigned UUIDs explicitly.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
arcadia 5;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
@@ -1537,6 +1893,14 @@ deterministic V1 migration, occurrence identity, coordinate bits, persistence,
|
||||
and model-history integration. Core validation and session tests cover domain
|
||||
mutation rules; native UI and API verification are separate evidence.
|
||||
|
||||
``cargo test -p oa-core --test r3_semantic --test r3_source --test r3_migration --locked``
|
||||
checks the closed facet catalog, partial decisions, constructor scopes, canonical
|
||||
agreement, target-only preservation, historic references, exact prepared impacts,
|
||||
source/JSON parity, comment ownership and explicit original1–4 migration. The
|
||||
source suite includes actual included-file save/reopen and prepared review
|
||||
writeback. These checks establish core/source behavior; they do not establish
|
||||
graphical or hosted API acceptance by themselves.
|
||||
|
||||
The research record in ``docs/research-v2-semantics.md`` distinguishes method
|
||||
requirements from Studio design choices and links to original UI evidence.
|
||||
The following sources inform this language without extending its scope:
|
||||
|
||||
+5129
-7
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,21 @@
|
||||
- **Evidence governs completion.** Original Capella was operated in an isolated example and its useful nesting/navigation/transition patterns informed the UI. Independent reviews, real native workflows and automated invariants complement each other. Platform and feature boundaries remain explicit in validation/scope documents.
|
||||
# First-release decisions (supersede narrower V2 exclusions below)
|
||||
|
||||
- R3 production adopts closed typed per-facet reviews (C), selected after the
|
||||
11-case proof in b2ab2b1 and independent adversarial review. Each self-tagged
|
||||
fact stores independently reviewed OA/SA typed values; absent facts remain
|
||||
Unreviewed, and NotApplicable distinguishes an absent constructor branch from
|
||||
an absent optional value. This supersedes recursive SemanticValue/string paths
|
||||
and typed-body carrier/mask/order-shadow baselines. Extra finite adapter code
|
||||
is accepted for fewer persisted-state invariants, not as a generic metamodel.
|
||||
Atomic keyed rows, target-only preserving member deltas, independent order,
|
||||
exact constructor groups, explicit mapping resets/canonical target agreement
|
||||
and actual prepared-impact review remain required. Keep the existing Command
|
||||
and transition planner boundaries. Artifact reviews attach to semantic traces.
|
||||
Schema/source5 is explicit, with unchanged data4 compatibility. The authoritative
|
||||
implementation/ownership/evidence contract is release-semantic-review-implementation.md;
|
||||
production implementation and release acceptance are still pending.
|
||||
|
||||
- OA/SA semantic scope follows `release-semantics-audit.md` R1–R6 and actual Capella workflow evidence. Later Arcadia layers remain future work; missing OA/SA reuse, topology, state/data contracts and semantic transition review do not become release limitations.
|
||||
- Preserve one typed Rust core and atomic validated model commands. Mission/capability links and process topology are independent of containment and diagram placement. Work/chain reuse uses stable reference occurrences rather than copied semantic objects; sequence guards belong to contextual links rather than global exchanges.
|
||||
- Desktop lint uses one background parser plus the latest queued source version. Accepted model checks are cached separately from draft diagnostics. Stale results cannot enter a different document or overwrite newer edits. The REST service prepares bounded candidates outside its global store lock and rechecks ETags during commit.
|
||||
@@ -69,3 +84,35 @@ the leak and native Windows/Linux tests verified the correction in316dc55.
|
||||
paths were proven ambiguous in native recursive/reused data. Browsing does not
|
||||
change the selected field or expression; choosing a field remains an explicit
|
||||
draft edit through the ordinary validated command. No persistent browser schema.
|
||||
## Semantic review proposals and historical admission (2026-09-06)
|
||||
|
||||
R3 stores closed, typed, independently reviewed OA/SA facet pairs. Constructor
|
||||
facts store discriminators only; explicit group decisions enumerate and retire
|
||||
their exact branch facts. No persisted recursive value tree, own-record carrier,
|
||||
review mask or order shadow is introduced. The complete contract is
|
||||
release-semantic-review-implementation.md.
|
||||
|
||||
Derive every source proposal from the same immutable initial project. Reset old
|
||||
mapping selectors before deriving scopes; compare all desired canonical target
|
||||
slots, including constructor removals, before one materialization. Audit selected
|
||||
values against both that result and the ordinarily prepared project. This prevents
|
||||
sequential last-write wins, even when one source introduces a member another
|
||||
constructor removes. Preserve target-only members during same-target-constructor
|
||||
reconciliation. Artifact attachment and explicit owner changes share the actual
|
||||
artifact's owner slot; child/incident operations similarly share actual parent or
|
||||
endpoint slots. Exact prepared cleanup impacts require explicit acknowledgement
|
||||
and may not contradict a selected Keep/Take.
|
||||
|
||||
Artifact metadata is observed by identified record after authorization against
|
||||
its initial attachment. An owner move therefore retains the actual resulting
|
||||
Owner/Name facts, including target-only links; Presence remains owner-relative.
|
||||
New metadata decisions require the current attached owner trace. Historical paired
|
||||
selectors survive disappearance of one side and suppress redundant one-sided
|
||||
projections. A new pairing cannot suppress prior one-sided history without reset.
|
||||
|
||||
Current resolution applicability and historical baseline admission are separate.
|
||||
New decisions require a compatible current family and an applicable side; historical
|
||||
typed facts can outlive former constructors/families and deleted references. Their
|
||||
typed reference visitor supports explicit reidentification but does not add live
|
||||
deletion dependencies. Derived linked-definition drift remains bounded and exposes
|
||||
missing review coverage instead of copying a shared definition into every baseline.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# First review of equal constructor discriminators
|
||||
|
||||
The review planner previously rejected every unchanged-constructor decision even
|
||||
when that discriminator had never been reviewed. The catalog correctly retained
|
||||
it as Unreviewed. An isolated executable against core346fb24 reproduced an ordinary
|
||||
DataType review that acknowledged all seven other facets but could not complete
|
||||
its own baseline. The same result occurred with no outer baseline, semantic=None,
|
||||
an empty semantic baseline, and a schema4 trace migrated to schema5. KeepTarget,
|
||||
TakeSource and an unchanged Candidate all returned "No constructor switch".
|
||||
Explicitly inserting only the valid discriminator fact made own drift Unchanged.
|
||||
This was a material completion defect for existing and migrated review workflows,
|
||||
without evidence of model corruption. Direct source editing of a valid baseline
|
||||
or an actual constructor switch were possible workarounds, not normal initial
|
||||
review behavior.
|
||||
|
||||
The agreed fix preserves the public request and stored schema. After explicit
|
||||
resets, a constructor with no historical fact has scalar scope if both current
|
||||
sides are applicable and equal and the proposed target discriminator is unchanged.
|
||||
KeepTarget, equal TakeSource and an unchanged-discriminator Candidate then record
|
||||
only that discriminator. They do not copy a constructor payload, expand descendants,
|
||||
retire historical branch facts or change model records.
|
||||
|
||||
Resolution overlap checks use this effective scope, so separately chosen leaves
|
||||
can accompany the first discriminator acknowledgement. Its exact canonical target
|
||||
slot enters the existing agreement map and both final-value audits. A different
|
||||
source may independently change a descendant, but cannot contradict the retained
|
||||
discriminator with a constructor switch. Final Candidate record equality still
|
||||
rejects changes to unselected fields.
|
||||
|
||||
Real proposals and historical discriminator changes retain the existing grouped
|
||||
scope. Repeated requests after the first acknowledgement or a completed switch
|
||||
still reject when no switch remains. Repeated Keep on a reviewed unequal pair
|
||||
cannot hide new bound/default drift. One-sided applicability stays on the group
|
||||
path; a discriminator inapplicable on both sides cannot be acknowledged, even
|
||||
after resetting its old fact. This avoids automatic history fabrication and does
|
||||
not suppress missing facts from the drift rollup.
|
||||
|
||||
Normative source5 text and its HTML copy now distinguish the first scalar
|
||||
acknowledgement from grouped constructor decisions. No source syntax, persisted
|
||||
baseline type, API request shape, migration rule or desktop code changed here.
|
||||
|
||||
Verification uses D:/CodexBuilds/Capella2-semantic-target exclusively. The eight
|
||||
new regressions cover all constructor families (valid Legacy OA/SA ports), both
|
||||
directions and all decisions; missing/empty/migrated history; simultaneous complete
|
||||
review; untouched and later changed descendants; reset; Candidate scope; canonical
|
||||
agreement in either source order; one-sided/disappeared MessageKind; and repeated
|
||||
unequal Keep/completed-switch rejection. The focused run passed all eight new
|
||||
tests and all 28 existing r3_semantic tests. Final verification passed all 353 core
|
||||
tests (33 test summaries, including doc tests) and strict Clippy for every core
|
||||
target:
|
||||
|
||||
```
|
||||
cargo test -p oa-core --locked --offline --target-dir D:/CodexBuilds/Capella2-semantic-target
|
||||
cargo clippy -p oa-core --all-targets --locked --offline --target-dir D:/CodexBuilds/Capella2-semantic-target -- -D warnings
|
||||
```
|
||||
|
||||
Logs are in C:/Users/sorok/AppData/Local/Temp/capella2-constructor-ack-review/
|
||||
as full-core.log and strict-clippy.log. Native/API release acceptance is outside
|
||||
this bounded fix.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Semantic desktop review — milestones 1 and 2
|
||||
|
||||
Status: milestones 1–2 implemented and desktop tests/build verified; not full GUI
|
||||
parity, native acceptance or release acceptance.
|
||||
Base: `d130013`. Dedicated build target:
|
||||
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
|
||||
|
||||
## Scope and decisions
|
||||
|
||||
- Add an explicit Review semantics action for existing traced counterparts, with
|
||||
canonical Operational/System comparisons in either application direction.
|
||||
Preserve the existing counterpart creation and prose workflow with clear labels.
|
||||
- Review one explicitly selected trace per dialog. Display grouped current and
|
||||
historical facts, own/linked drift and incomplete review. Names never infer
|
||||
reference correspondence; provide explicit reference and trace choices.
|
||||
- Support every applicable scalar leaf through Keep, Take and typed final-value
|
||||
forms. Candidate records contain the complete combined selected result. Identity,
|
||||
alias and layer are protected. The closed core remains the validation boundary.
|
||||
- State.Kind is a single structural scalar. Other constructor switches, keyed
|
||||
membership/order/annotation/binding/attachment decisions and pairing resets belong
|
||||
to milestone 3; show them read-only with an honest explanation.
|
||||
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
|
||||
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
|
||||
previously reviewed constructors remain governed by the core's switch rules.
|
||||
- Show exact additional prepared impacts and require explicit acceptance. Apply
|
||||
validates the stored semantic plan and executes its exact command batch through
|
||||
Document, preserving source, baselines and one-step undo/redo. Do not place or
|
||||
reveal diagram objects by mutating the model after preview.
|
||||
- Retain typed drafts and review choices on invalid input, planning conflicts,
|
||||
stale plans and failed source writeback. Display actionable errors in the dialog.
|
||||
|
||||
## Milestones and acceptance
|
||||
|
||||
1. Grouped read-only review, named trace/reference choices, exact comparisons and
|
||||
honest traceability rollups; compact layout usable at 1100 by 700.
|
||||
2. Scalar decisions and typed candidates; immutable initial observations; combined
|
||||
final records; exact impact acceptance; atomic document/source commit. Focused
|
||||
App event/source/history tests, strict Clippy and a dedicated desktop build.
|
||||
3. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle,
|
||||
multi-source review and remaining source/REST parity cases. Still outstanding.
|
||||
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
|
||||
|
||||
## Verification
|
||||
|
||||
Verified against core `6bb2583`, using only the dedicated desktop target:
|
||||
|
||||
```powershell
|
||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
|
||||
cargo test --locked -p oa-desktop --quiet
|
||||
cargo clippy --locked -p oa-desktop --all-targets -- -D warnings
|
||||
cargo build --locked -p oa-desktop
|
||||
```
|
||||
|
||||
All **272 desktop tests passed**, including **11 new semantic review tests**.
|
||||
Strict Clippy and the desktop build passed. The existing migration test's header
|
||||
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
|
||||
|
||||
Coverage includes both-direction Keep/Take/Candidate with exact comments and
|
||||
source/model/baseline undo/redo; independent selected trace semantic histories;
|
||||
invalid numeric and protected-property edits; stale-plan and source-writeback
|
||||
rejection; typed reference mapping mixed with candidates; honest partial rollups;
|
||||
equal-kind acknowledgment without implicit descendants; State.Kind candidates;
|
||||
relationship role/bound candidates with retained identity/endpoints; and exact
|
||||
inherited-annotation cleanup approval, cleared when decisions change. Unselected
|
||||
invalid scalar drafts remain retained without contaminating another candidate.
|
||||
|
||||
Built executable:
|
||||
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
|
||||
The build target is idle. No native application was launched by this task.
|
||||
|
||||
## Remaining work
|
||||
|
||||
Milestone 3 must add explicit constructor closure/replacement, keyed and ordered
|
||||
decisions, historical pairing reset, complete artifact lifecycle and multi-source
|
||||
review. These facts are currently inspectable, with unsupported writes visibly
|
||||
identified. The reference table supplies explicit mappings for scalar reference
|
||||
Take; it does not claim to complete a reset or establish keyed acknowledgments.
|
||||
|
||||
Fresh independent scalar GUI review and root-owned native verification are still
|
||||
required before merging this milestone into release acceptance. In particular,
|
||||
validate the 1100×700 layout, keyboard/focus continuity, long historical values,
|
||||
reference selection, source reopening and prepared-impact inspection in the app.
|
||||
@@ -0,0 +1,267 @@
|
||||
# R3 semantic review implementation freeze — schema/source 5
|
||||
|
||||
Status: production implementation authorized; not accepted or complete. Isolated
|
||||
worktree `D:/CodexBuilds/Capella2-semantic-review`, branch `codex/semantic-review`,
|
||||
base `ab661f2d332f540673ee0e2003b454c1765332bc`; dedicated target
|
||||
`D:/CodexBuilds/Capella2-semantic-target`. R6 native work remains root-owned in DATA.
|
||||
|
||||
## Decision and evidence
|
||||
|
||||
Adopt C: a closed, typed, self-tagged review value per semantic facet. The
|
||||
throwaway C proof is `b2ab2b1feb9b0ada03cd9d0974749bd7fd28c7f8` in the separate
|
||||
transition-probe worktree. Its 11 passing tests comprise three B1 comparison
|
||||
cases and eight C cases, including an independent adversarial review's concrete
|
||||
counterexamples. Additional type/adapter code is accepted for fewer persisted
|
||||
state invariants; this is not a claim that C uses fewer lines.
|
||||
|
||||
This supersedes the recursive `SemanticValue`/string-path recommendation in
|
||||
release-transition-contract.md and the interim typed-carrier/mask/order-shadow
|
||||
recommendation. Keep its engineering requirements: partial deliberate review,
|
||||
explicit mapping, target-only preservation, honest unreviewed state, source/JSON
|
||||
parity, and one stale-checked validated transaction. No generic metamodel, graph
|
||||
synchronization service, runtime value tree, or second mutation engine is added.
|
||||
|
||||
Compiled core catalog: `semantic/types.rs` contains 84 scalar selectors across 13
|
||||
families, nine reference collections, two ordered collections, and the closed
|
||||
Literal/Annotation/Binding/Child/Incident/Artifact facts. The catalog coverage test
|
||||
enumerates every current element payload, constructor branch and relation kind;
|
||||
an unrepresented payload field fails that gate. Serialization paths used by the
|
||||
private adapter are fixed implementation details, never accepted user selectors.
|
||||
|
||||
## Persisted shape and granularity
|
||||
|
||||
`TraceBaseline` retains existing `oa`/`sa: TracedProperties` and adds
|
||||
`#[serde(default)] semantic: Option<SemanticBaseline>`. Old absence is Unreviewed;
|
||||
never synthesize historical semantic values from today's project. Baseline and
|
||||
Trace use PartialEq, because typed semantic values include finite f64.
|
||||
|
||||
`SemanticBaseline { facets: Vec<FacetReview> }` stores only independently reviewed
|
||||
facts. `FacetReview` is a closed self-tagged enum, nested by actual model family;
|
||||
each leaf carries `Reviewed<T> { oa: Observed<T>, sa: Observed<T> }`.
|
||||
`Observed<T>` is `NotApplicable | Value(T)`. T is fixed by the enum variant, reusing
|
||||
existing model types (DataValue, TypeRef, MessageValue, LifelineBinding, port/state
|
||||
enums and artifact metadata). No entry means Unreviewed. `Value(None)` is a real
|
||||
absent optional value, distinct from a missing constructor branch. Reject leaves
|
||||
inapplicable on both sides and selectors outside the compared record family.
|
||||
|
||||
Historical keys use `KeyPair<K> { oa: Option<K>, sa: Option<K> }` in canonical
|
||||
OA/SA coordinates. Annotation keys are the entire `(path, field)`; binding keys
|
||||
are the referenced lifeline. An annotation or binding is one atomic optional row;
|
||||
retargeting its key is explicit removal/addition, with no heuristic rename.
|
||||
Other rows remain independently reviewable. Scalar field defaults are one atomic
|
||||
optional vector because their repeated values have no stable identity.
|
||||
|
||||
Reference collection members and enum literals are individually reviewed facts.
|
||||
Enum keys are exact case-sensitive strings, not list indexes. Source collection
|
||||
Take applies additions/removals, preserving independently added target members.
|
||||
Ordering is its own typed Vec<Id> fact; membership acknowledgement never refreshes
|
||||
it. InterfaceItemAllocation order/protocols remain fields of the identified
|
||||
relation, not a second writable Interface.items list.
|
||||
|
||||
Constructor groups are explicit, narrow semantic scopes. Data definition owns
|
||||
its variant fields and coupled default; PortContract owns contract plus Legacy
|
||||
payload; Event owns its variant payload; Occurrence owns its variant payload but
|
||||
not common pre/postconditions. Nested MessageKind/Timer changes expose dependent
|
||||
exchange/reply/value changes. StateKind is a semantic structural choice even
|
||||
though it changes Element::kind(). Accept a discriminator switch and its whole
|
||||
introduced/removed group atomically, retiring obsolete branch facts. Unchanged
|
||||
kind cannot acknowledge unrelated bound/default drift by a Keep shortcut. An
|
||||
explicit actual target switch may reconsider an earlier deliberate Keep.
|
||||
|
||||
Persist only `DataConstructor`, `PortConstructor`, `OccurrenceConstructor`,
|
||||
`EventConstructor` and `MessageConstructor` discriminator enums (State uses the
|
||||
existing StateKind). A grouped decision uses a transient typed record, retires
|
||||
obsolete branch facts and stores each resulting descendant independently. A
|
||||
constructor payload snapshot must never overlap later independently reviewed
|
||||
leaves. Repeating Keep on an already acknowledged unequal discriminator pair
|
||||
cannot acknowledge newly changed bounds/defaults. Constructor retirement writes
|
||||
explicit NotApplicable target slots for agreement checking against other reviews.
|
||||
|
||||
Artifact reviews are attached facts of the owning semantic trace; they do not
|
||||
widen ordinary Trace endpoint compatibility. Cover artifact membership and
|
||||
name/description/owner/relation/URI/revision/fragment, preserving target-only links.
|
||||
Do not fetch artifact content. Historical UUIDs remain after ordinary deletion,
|
||||
and explicit identity remapping visits keys and typed values without treating
|
||||
them as live graph edges.
|
||||
|
||||
## Complete field catalog
|
||||
|
||||
- Headers: existing name/description review; element parent. Identity, alias,
|
||||
layer and placements are not incidental semantic edits.
|
||||
- OA/SA primitive bodies have no payload; Process/FunctionalChain have step
|
||||
membership/order. Shared Machine/Region have no own payload.
|
||||
- ExchangeItem nature/mechanism/supertype; Interface supertype and independently
|
||||
identified allocation relations.
|
||||
- DataType constructor, integer/real bounds, string lengths, enum members,
|
||||
Record supertype, default and unit. DataField TypeRef, multiplicity bounds,
|
||||
unit, direction, storage and optional default vector.
|
||||
- Port constructor, function input/output item membership, component
|
||||
kind/orientation and Legacy direction/interface. Existing typed port generation,
|
||||
explicit resolution/split and unsupported OA mappings remain authoritative.
|
||||
- Scenario viewpoint/context/pre/postconditions/steps; Lifeline represented ref.
|
||||
- Occurrence constructor/common pre/postconditions; Exchange endpoints/reference,
|
||||
message kind/Timer expression, reply correlation and qualified annotations;
|
||||
Action refs; Control kind; SequenceReference; ScenarioUse ref/bindings; State
|
||||
refs; Fragment operator/branches; Branch guard/ordered occurrences.
|
||||
- Event Signal item membership, Time kind/expression, Change expression; State
|
||||
kind and entry/exit/do prose.
|
||||
- All relation source/target endpoints; interface allocation order/protocols;
|
||||
association roles/multiplicities/navigation; exchange item sets/ports/orientation;
|
||||
capability-extend condition; sequence guard; state-transition kind, trigger/effect
|
||||
sets and separate prose; StateAction phase. Other relation variants are endpoint-only.
|
||||
- Direct owned-child membership, incident relation identity with endpoint role,
|
||||
and attached artifact membership/properties. Rollups distinguish own drift from
|
||||
linked impact; shared referenced definitions are not copied into every baseline.
|
||||
|
||||
## Review and command boundary
|
||||
|
||||
Use the existing transition selection/dependency/mapping planner and ordinary
|
||||
Command preparation. Keep the prose request/API compatible; add sibling typed
|
||||
semantic request/preview/apply helpers. Whole existing typed records may appear
|
||||
as transient candidates or impact snapshots, never as persisted baseline carriers.
|
||||
|
||||
The implemented sibling boundary is `SemanticTransitionRequest { transition,
|
||||
traces, references, reviews, candidates, accepted_impacts }`.
|
||||
`traces` selects a particular trace by source UUID when one endpoint pair has
|
||||
several traces; `references` is canonical OA→SA correspondence, including old
|
||||
deleted IDs. `SemanticReviewRequest { source, resolutions, reset }` and
|
||||
`FacetResolution { facet, decision }` use KeepTarget/TakeSource/Candidate.
|
||||
Candidates are full existing typed Element/Relation/Artifact records with identity
|
||||
and aliases protected; they must equal the result of selected facets exactly.
|
||||
`plan_semantic_transition`, `prepare_semantic_transition` and
|
||||
`apply_semantic_transition` reuse the existing mapping planner and Command batch.
|
||||
Previews expose typed current/drift facts, acknowledged and retired selectors,
|
||||
exact typed prepared impacts, conflicts, and a stale-check fingerprint.
|
||||
|
||||
Artifact metadata slots are keyed by the actual artifact UUID, independent of
|
||||
which owning trace reaches them. Presence and owner share the ownership slot;
|
||||
metadata selection requires the currently attached owner. An explicit Owner
|
||||
candidate can move that link to another valid owner; its canonical owner slot
|
||||
must agree with any selected attachment decision. Creating a link
|
||||
requires a complete candidate and explicit review of every introduced metadata
|
||||
field. Removing a link retires its metadata and checks those removed slots against
|
||||
other decisions. Historical artifact keys/owners are visited only by explicit
|
||||
identity remapping, alongside all other historical UUIDs.
|
||||
|
||||
Attachment authorization uses the immutable initial snapshot; metadata observations
|
||||
use the identified artifact record. Thus a target-only Owner or Owner+Name candidate
|
||||
records the actual resulting owner/metadata after a move, independent of facet
|
||||
order. Presence remains relative to the trace owner. A subsequent metadata decision
|
||||
through the former owner trace is rejected, while its historical observation remains
|
||||
available for drift. This avoids replacing an explicit owner value with both-side NA.
|
||||
|
||||
Freeze mappings before proposals. Resolve all live and historical reference
|
||||
members through explicit correspondence; no display-name matching. Many sources
|
||||
may affect a target only when resolved deltas agree for each canonical target
|
||||
slot. A source/key-pair collision or contradictory target delta is a conflict,
|
||||
independent of request order. Mapping changes require explicit invalidation/reset
|
||||
of affected old reviews; never silently replace or accumulate overlapping pairs.
|
||||
|
||||
An established paired selector remains the identity when one side's member or
|
||||
constructor branch disappears. It suppresses the redundant generated one-sided
|
||||
projection, preserving an NA/remaining-value observation. The reverse case is
|
||||
different: a newly appearing pair does not suppress old one-sided history and
|
||||
requires an explicit reset. Apply this rule to the closed keyed selector catalog,
|
||||
not only enumeration or constructor special cases.
|
||||
|
||||
Every selected source derives desired values from the same immutable initial
|
||||
project. Apply explicit baseline resets before deriving its current catalog or
|
||||
constructor scope. Union all selected canonical slots across constructor scopes,
|
||||
including introduced members and removals, and reject disagreement before applying
|
||||
any writes. Materialize the agreed result once and compare every selected desired
|
||||
slot against it, then repeat that audit after ordinary command preparation.
|
||||
An exact approved cleanup impact still cannot contradict a selected Keep/Take.
|
||||
This invariant replaces sequential proposal mutation and order-dependent audits.
|
||||
When the target already has the proposed constructor, preserve target-only members
|
||||
while reconciling its group; a source constructor change is not whole-list deletion
|
||||
authority. Independently inferred orders must agree; commuting member removals
|
||||
filter those orders to surviving members without refreshing order history.
|
||||
|
||||
Compare each side against its own reviewed value, retaining intentionally unequal
|
||||
pairs. Keep acknowledges only the selected current source/retained target fact.
|
||||
Take or a typed candidate acknowledges only the selected resulting fact. Untouched
|
||||
changes remain drift. Preview unreviewed differences rather than falsely claiming
|
||||
unchanged semantics. A selected constructor group names every fact it acknowledges.
|
||||
|
||||
New resolution admission requires the appropriate current record family and at
|
||||
least one applicable current side. Persisted historical facts are different: their
|
||||
typed former family/constructor and deleted UUIDs remain valid history even after
|
||||
the live endpoints change. Validate their closed types, keys, duplicate slots,
|
||||
finite values, text and work limits without requiring present-day live references
|
||||
or present-day family applicability. Historical observations are never live edges.
|
||||
|
||||
Linked shared-definition drift is derived, not copied into each referring baseline.
|
||||
The bounded walk follows reusable data fields/types, exchange items, interfaces,
|
||||
events and interface allocation declarations, stopping at 4096 objects per side
|
||||
or 32768 edges with an observable diagnostic. Parent edges and arbitrary architecture
|
||||
neighborhoods do not expand that walk. Missing pairs/reviews remain Unreviewed;
|
||||
own and linked rollups explicitly retain their unreviewed counts.
|
||||
|
||||
Preserve target-only members and records. Removing source membership proposes
|
||||
unlinking, not deletion of target semantic objects. Ordered insertion is automatic
|
||||
only with a unique unchanged mapped anchor; ambiguous insertion/reorder requires
|
||||
an explicit final target order containing every surviving member once. Parent and
|
||||
relation endpoint edits remain explicit canonical operations, with aggregate
|
||||
membership effects linked to those same operations rather than competing commands.
|
||||
|
||||
Prepare one complete candidate, then compare the actual prepared project against
|
||||
the explicitly reviewed result. Existing cleanup (including inherited annotation
|
||||
pruning) can cause additional changes: expose their exact typed before/after
|
||||
impact and require explicit review. Cancellation, stale fingerprint/ETag, missing
|
||||
resolution or invalid candidate leaves model, baseline, revision/history and disk
|
||||
unchanged. Baseline updates join the same ordinary atomic transaction. Repeated
|
||||
application is idempotent. The plan is still a client candidate, not a security
|
||||
receipt; ordinary validation is never bypassed.
|
||||
|
||||
## Source/schema handoff and ownership
|
||||
|
||||
Core owns model/types, the closed semantic catalog/helpers, transition integration,
|
||||
reference/remap/admission validation, lib registration and semantic core tests.
|
||||
Source worker owns persistence.rs version dispatch, language/source_project/source
|
||||
files, V4 compatibility migration and source/migration tests. Desktop/server are
|
||||
later separate integrations. Communicate compiled public types before dependent
|
||||
source edits. Coordinate the dedicated target; no concurrent Cargo mutations.
|
||||
|
||||
Agreed internal helper, rooted at SemanticBaseline's serde tree:
|
||||
|
||||
```rust
|
||||
pub(crate) enum HistoricalSourceKind { Reference, Enum }
|
||||
pub(crate) fn historical_source_slots(
|
||||
baseline: &SemanticBaseline,
|
||||
) -> Vec<(Vec<String>, HistoricalSourceKind)>;
|
||||
```
|
||||
|
||||
Paths are internal field names/list indexes, never persisted or public SemanticPath.
|
||||
Core derives slots from the same finite typed leaf catalog, including nested Id,
|
||||
model enums, Option/Vec, annotations/bindings and NotApplicable observations.
|
||||
Source can normalize historical AST leaves to temporary JSON, deserialize the
|
||||
existing typed SemanticBaseline, then check original token classes with this map.
|
||||
References require an explicit historical `uuid "..."`; enum leaves require bare
|
||||
words; other string leaves require quotes. Historical @alias is rejected. This
|
||||
handles Reviewed oa/sa text fields without weakening existing live @alias rules.
|
||||
Formatting and token/comment identity use the same slot metadata.
|
||||
|
||||
Schema/source5 is explicit. V4 migration freezes only changed Trace/TraceBaseline
|
||||
shapes plus the closed old constructor catalog, reusing unchanged typed model
|
||||
records; retain V1/V2/V3 adapters and real compatibility fixtures. No public
|
||||
intermediate5 acceptance before full catalog, baseline roundtrip, source/JSON/API
|
||||
agreement and coupled persistence are verified. Earlier source/data4 remains real
|
||||
compatibility input, not a silently mutable contract.
|
||||
|
||||
## Verification gates
|
||||
|
||||
First close pure types/catalog/drift and typed source admission, then mapped review
|
||||
and complete atomic preparation. Cover every listed field family with observed
|
||||
mutations, including both directions and same-kind counterparts. Required focused
|
||||
cases: partial Keep/Take; constructors/defaults/units; row identity and changed old
|
||||
deleted references; target-only fields/triggers/literals/bindings/artifacts; order
|
||||
ambiguity; mapping reset and agreeing/conflicting many-to-one slots; wrong-family
|
||||
and both-side-NA admission; old semantic absence; prepared cleanup impacts; stale
|
||||
plan; duplicate decisions; no-op acknowledgement; repeat apply; undo/redo and invalid
|
||||
rollback. Test finite bounds and bounded nested/historical reference work.
|
||||
|
||||
Then source/JSON historical UUID and mixed-constructor roundtrip, explicit migration,
|
||||
comment/identity preservation, actual REST ETag/disk/restart, native compact/normal
|
||||
review UI, independent semantics/correctness review and Windows/Linux verification.
|
||||
Do not quietly reduce this to scalar-only reconciliation. Incremental commits are
|
||||
implementation checkpoints, not release acceptance.
|
||||
@@ -0,0 +1,39 @@
|
||||
# R3 source and migration integration
|
||||
|
||||
Status: source/persistence integration implemented and verified, 2026-09-06. Worktree: `D:/CodexBuilds/Capella2-semantic-review`, branch `codex/semantic-review`, base `ab661f2`. This plan owns only source/persistence integration; it does not claim overall R3 acceptance. The adapter uses the published core facet types and typed historical slot visitor.
|
||||
|
||||
## Ownership and fixed boundary
|
||||
|
||||
The source worker owns `persistence.rs`, a new strict schema4 adapter, `language.rs` and its source submodules, `source_project`/`source_files`, and focused source/migration tests. Core owns `model.rs`, `lib.rs`, the semantic review module, transition preparation, historical-reference remapping and semantic admission. Desktop/server integration belongs to the lead. Cargo target ownership is coordinated before any invocation.
|
||||
|
||||
The stable outer shape is `TraceBaseline { oa, sa, semantic: Option<SemanticBaseline> }`: existing prose pairs remain exact; absent semantic review means Unreviewed. SemanticBaseline contains closed, self-tagged typed reviewed pairs. Whole typed records exist only as review candidates, never as persisted carriers, masks or checkpoints. `TraceBaseline` and `Trace` become PartialEq because reviewed data includes finite real values.
|
||||
|
||||
## Explicit compatibility
|
||||
|
||||
1. Current JSON and authored source become schema/source5. Preserve real schema4/source4 fixtures before updating any tests intended to describe current syntax. Existing V1/V2 goldens and real V3 fixtures remain unchanged.
|
||||
2. Add frozen `Trace4`/`TraceBaseline4` with denied unknown fields and exact old prose values. Reuse unchanged typed elements, relations, diagrams and artifacts in an explicit Project4 envelope with duplicate-map rejection. Do not copy the full metamodel. A schema4 input containing a semantic baseline must reject instead of silently accepting a future payload.
|
||||
3. Adapt frozen schema3 trace decoding to use the same old trace shape; schema2 already has a frozen model. Ensure V1/V2/V3 paths enter the explicit review upgrade before returning current5, preserving their existing data/port migration report details. The original reported `from_schema` remains unchanged and `to_schema` is 5.
|
||||
4. A migrated trace keeps IDs, aliases, rationale, endpoints and exact prose baselines; it receives semantic=None. Do not construct reviewed semantic observations from today's model. No file write, hosted revision advance or history entry occurs during decode.
|
||||
5. Source3/source4 bundle compilation passes through `resolve_declarations`, bypassing `parse_document`; apply the same strict legacy trace decoding there. Upgrade authored4 with a version-token edit only, plus UUID stabilization when actually missing. Authored3 retains its existing targeted data edits and finishes at source5. Preserve includes, lexical ownership, comments, line endings and file layout; no canonical-bundle fallback.
|
||||
|
||||
## Typed historical source context
|
||||
|
||||
Add an explicit `uuid "..."` AST atom. It is permitted only in reference slots of a typed semantic facet under a trace baseline. Ordinary live references continue to require `@alias`; trace endpoints, parents, ports, payload paths and other live fields do not gain a quoted-UUID escape hatch. Historical references never require a current alias or live object.
|
||||
|
||||
Coordinate a closed source-shape/reference-slot catalog with the core facet types. `oa`, `sa` and `Value` wrappers cannot use the existing leaf-name reference heuristic: the same spelling can wrap a bound, enum, string, optional UUID, keyed row or order vector. Reject a historical UUID atom in text/numeric/enum slots and reject `@alias` or a plain quoted UUID in historical reference slots. Typed serde admission remains responsible for exact payload shape; the source adapter preserves the token category distinction. No public generic SemanticPath/SemanticValue tree or escaped JSON blob is introduced.
|
||||
|
||||
Canonical output prints historical UUID atoms even when the object currently exists, making the historical meaning stable across deletion. Live aliases remain canonical for live references. Source analysis/indexing treats both-token UUID atoms as one value span. The targeted updater compares keyed facet rows by their closed selector identity, preserves comments on retained rows, and updates all historical UUID tokens when core reidentifies an identity. A UUID-looking ordinary text annotation must stay ordinary text.
|
||||
|
||||
## Verification required for handoff
|
||||
|
||||
- Strict schema4 JSON and source4 migration preserves existing trace prose, IDs, aliases, ports and unrelated records, with semantic=None and no fabricated acknowledgement. Unknown schema5 semantic data under a version4 header rejects. Duplicate maps/fields remain rejected.
|
||||
- Current5 canonical source and JSON roundtrip a representative typed review baseline with optional/unbounded values, NotApplicable constructor branches, ordered membership, complete `(path, field)` annotation keys, bindings and deleted historical UUIDs. Historical UUID syntax is rejected in live fields, and live aliases/plain strings are rejected in historical ID slots.
|
||||
- Apply and format a real reviewed change, retain an intentionally different target value, delete a previously referenced object, then roundtrip and reidentify without losing history. Confirm core deletion does not prune historical values and real reidentification changes the exact corresponding tokens.
|
||||
- A multi-file authored4 bundle with nested declarations and commented trace fields upgrades via targeted edits; a later facet-row update/reidentify preserves includes, comments and unrelated bytes. Exercise disk save/reload through existing source-file behavior where it adds evidence beyond the pure bundle test.
|
||||
- Exercise the actual `compile_bundle` path as well as single-document parse. Preserve original V1/V2/V3 compatibility coverage and current source writer/identity/nesting regressions. Report test names and actual results only after the coordinated Cargo turn.
|
||||
|
||||
## Recorded evidence
|
||||
|
||||
The completed regression run passed 127 tests: 20 library tests, 16 language, 8 port migration, 3 authored4 port source, 5 R3 migration, 7 R3 source, 7 R6 migration, 5 R6 source, 8 source files, 3 identity updates, 12 nested updates, and 33 source project tests. These cover explicit original1/2/3 and frozen4 upgrade, strict older-header rejection of semantic data, unchanged reviewed prose, authored4 include/nesting/comment preservation, typed semantic source/JSON roundtrips with absent historical IDs, token context admission, row comment ownership through reordering and reidentification, and deletion retaining historical review. `cargo check -p oa-core --lib` also passed with the new dispatch and source adapter.
|
||||
|
||||
The R3 source tests also pass actual included-review disk load, targeted edit, save and reopen, plus a real prepared semantic review transaction that takes the source name while retaining the intentionally different target unit and original prose baseline. The source regression suite's prior future-header rejection was updated from 5 to 6, and current-syntax test templates now use 5. The real source4 typed-port tests intentionally remain authored4 and assert their explicit migration report; frozen V1/V2/V3 inputs remain unchanged. Strict Clippy passed for the library and all eleven selected integration suites with `-D warnings`, using the shared vendored config and dedicated target. Later planner changes remain the core worker's responsibility to recheck against `r3_source`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# R3 semantic transition review contract proposal
|
||||
# R3 semantic transition review — historical proposal
|
||||
|
||||
Status: read-only integration design, 2026-09-05; not implemented. The lead requested this while R5 topology was finishing. R5 final source/API verification takes precedence. R6 properties below refer to the proposed `release-data-contract.md` shapes and must follow the lead's final R6 freeze.
|
||||
Status: the representation recommendation below is superseded by `release-semantic-review-implementation.md`. Root selected closed typed per-facet reviews (C) after the 11-case executable comparison and independent critique. Recursive SemanticValue/string paths and typed-carrier/mask storage are not the production choice. The engineering requirements below remain design context where consistent with that authoritative implementation contract. Production R3 is not yet accepted.
|
||||
|
||||
## Current boundary and concrete outcome
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: read-only integration design, 2026-09-05; not implemented. The lead requ
|
||||
|
||||
An engineer must see a changed range, item list, parent, guard, occurrence, event trigger or interface contract; inspect its mapped proposal; accept selected changes or preserve intentional target differences; apply once, undo, and repeat without overwriting independent work. Neither direction is an automatic inverse. The original Capella iterative transition reference already linked in `release-semantics-audit.md` motivates conservative target comparison; the detailed decisions here are Studio's own contract.
|
||||
|
||||
## Recommended exact public types
|
||||
## Rejected historical representation proposal
|
||||
|
||||
Keep Name/Description unchanged for existing callers. Add a closed-catalog semantic field selector and typed values:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user