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")
|
||||
Reference in New Issue
Block a user