diff --git a/Cargo.lock b/Cargo.lock index a812d5d2..3c1a0b29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,6 +1178,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -3815,6 +3821,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tracing", + "ufo-types", ] [[package]] @@ -5648,6 +5655,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -8732,6 +8750,27 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "sysml-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysml-v2-parser" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db89688b6bab9dacbf8150e3f32f636decd36e34fcb8aaacbdb52fe281a9134" +dependencies = [ + "log", + "nom 8.0.0", + "nom_locate", + "stacker", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -9806,7 +9845,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9927,6 +9966,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "sysml-v2-parser", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 0c7cfece..da89e327 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/holon-viz-wasm", "crates/beankeeper-bridge", "crates/ledgerr-desktop-agent", + "crates/sysml-derive", ] resolver = "2" @@ -52,6 +53,9 @@ kasuari = "0.4" statig = "0.4" anyhow = "1" ordered-float = { version = "4", features = ["serde"] } +syn = "2" +quote = "1" +proc-macro2 = "1" b00t-reflect-types = { path = "crates/b00t-reflect-types" } b00t-reflect = { path = "crates/b00t-reflect" } ledger-core = { path = "crates/ledger-core" } diff --git a/crates/holon-viz/Cargo.toml b/crates/holon-viz/Cargo.toml index 9c34a113..4653543e 100644 --- a/crates/holon-viz/Cargo.toml +++ b/crates/holon-viz/Cargo.toml @@ -22,6 +22,11 @@ path = "src/bin/demo.rs" [dev-dependencies] serde_json.workspace = true tempfile.workspace = true +# Test-only: validates SysmlV2Emitter's own output against a real SysML v2 +# grammar (see docs/sysml-v2-parser-spike.md). Dev-dependency only, since +# ufo-types' sysml-v2-parser dependency is not wasm32-compatible and +# holon-viz-wasm must never pull it in. +ufo-types = { path = "../ufo-types" } # CDP integration test dependency — only required when TEST_WEBVIEW=1 is set # cargo test --test holon_viz_visual_e2e # e2e with live Tauri WebView2 # cargo test # unit + integration (no webview) diff --git a/crates/holon-viz/src/emitter.rs b/crates/holon-viz/src/emitter.rs index 7a69ce74..35093c54 100644 --- a/crates/holon-viz/src/emitter.rs +++ b/crates/holon-viz/src/emitter.rs @@ -7,7 +7,7 @@ use crate::cytoscape::CytoscapeGraph; /// Emits SysML-v2 textual block definition fragments from a [`CytoscapeGraph`]. /// -/// Output is a minimal SysML-v2 `package` block containing one `block def` +/// Output is a minimal SysML-v2 `package` block containing one `part def` /// per node and `part def` references for containment edges. pub struct SysmlV2Emitter; @@ -21,8 +21,17 @@ impl SysmlV2Emitter { for node in &graph.nodes { let safe_label = sanitize_sysml_id(&node.data.label); + // The trailing comment is on its own line, and the closing `}` + // on its own line after it -- putting them on the same line + // (as this used to) puts the brace inside the `//` comment, + // leaving the block syntactically unclosed. `part def`, not + // `block def`: SysML v1 called this construct `Block`; SysML + // v2 renamed the equivalent concept to `part def`, and `block` + // is not a SysML v2 keyword at all. See + // docs/sysml-v2-parser-spike.md for how this was found (fed + // through the real `sysml-v2-parser` crate, not eyeballed). out.push_str(&format!( - " block def {} {{ // id: {}, kind: {} }}\n", + " part def {} {{\n // id: {}, kind: {}\n }}\n", safe_label, node.data.id, node.data.kind )); } @@ -32,7 +41,7 @@ impl SysmlV2Emitter { let src_label = find_label(graph, &edge.data.source); let tgt_label = find_label(graph, &edge.data.target); out.push_str(&format!( - " part def {} : {} {{ // edge: {} }}\n", + " part def {} : {} {{\n // edge: {}\n }}\n", sanitize_sysml_id(&tgt_label), sanitize_sysml_id(&src_label), edge.data.id diff --git a/crates/holon-viz/tests/holon_viz_comprehensive.rs b/crates/holon-viz/tests/holon_viz_comprehensive.rs index df8bf8b5..c5a4cd36 100644 --- a/crates/holon-viz/tests/holon_viz_comprehensive.rs +++ b/crates/holon-viz/tests/holon_viz_comprehensive.rs @@ -501,8 +501,8 @@ fn test_27_sysml_v2_empty() { let out = SysmlV2Emitter::emit(&empty_graph()); assert!(out.contains("package HolonModel")); assert!(out.contains("}")); - // No block def nodes - assert_eq!(out.matches("block def").count(), 0); + // No part def nodes + assert_eq!(out.matches("part def").count(), 0); } #[test] @@ -511,7 +511,10 @@ fn test_28_sysml_v2_single_node() { let g = CytoscapeGraph::from_holons(&[h]); let out = SysmlV2Emitter::emit(&g); assert!(out.contains("package HolonModel")); - assert!(out.contains("block def Alpha")); + // SysML v2 renamed SysML v1's `Block` to `part def`; `block def` is not + // valid SysML v2 syntax at all (confirmed against the real + // sysml-v2-parser crate — see docs/sysml-v2-parser-spike.md). + assert!(out.contains("part def Alpha")); } #[test] @@ -774,10 +777,14 @@ fn test_40_cross_format_consistency() { // SysML-v2 let sysml = SysmlV2Emitter::emit(&g); - let sysml_block_count = sysml.matches("block def").count(); + // Both nodes and containment edges emit `part def` (SysML v2 has one + // keyword for this, unlike the old node="block def"/edge="part def" + // split): 2 node defs + 1 containment-edge def for this 2-node, + // 1-edge graph = 3. + let sysml_block_count = sysml.matches("part def").count(); assert_eq!( - sysml_block_count, 2, - "SysML-v2 should contain 2 block definitions, got {}", + sysml_block_count, 3, + "SysML-v2 should contain 3 part definitions (2 nodes + 1 containment edge), got {}", sysml_block_count ); diff --git a/crates/holon-viz/tests/sysml_v2_roundtrip.rs b/crates/holon-viz/tests/sysml_v2_roundtrip.rs new file mode 100644 index 00000000..549e8215 --- /dev/null +++ b/crates/holon-viz/tests/sysml_v2_roundtrip.rs @@ -0,0 +1,57 @@ +//! Round-trips `SysmlV2Emitter::emit()`'s output through the real +//! `sysml-v2-parser` crate (wired in via `ufo_types::sysml`). +//! +//! `docs/sysml-v2-parser-spike.md` found `SysmlV2Emitter`'s output didn't +//! parse as SysML v2 at all: a `//` comment swallowed the closing `}`, and +//! it emitted SysML v1's `block def` instead of SysML v2's `part def`. Both +//! are fixed in `src/emitter.rs`. This test is the concrete "did we close +//! the round-trip" signal the spike asked for: it fails loudly if either +//! regresses, instead of only being checked by eyeballing the output text. + +use holon_viz::{CytoscapeGraph, Holon, HolonKind, SysmlV2Emitter}; +use ufo_types::sysml::validate_sysml_v2; + +#[test] +fn emitted_output_is_valid_sysml_v2_for_a_single_node() { + let h = Holon::root("alpha-id", "Alpha", HolonKind::SysmlBlock); + let g = CytoscapeGraph::from_holons(&[h]); + let emitted = SysmlV2Emitter::emit(&g); + + let result = validate_sysml_v2(&emitted); + assert!( + result.disposition.is_satisfied(), + "SysmlV2Emitter output failed to parse as SysML v2: {:?}\n---\n{emitted}", + result.disposition + ); +} + +#[test] +fn emitted_output_is_valid_sysml_v2_for_a_holarchy_with_containment_edges() { + let holons = vec![ + Holon::root("pipeline", "Tax Ledger Pipeline", HolonKind::CapsuleGroup), + Holon::child("ingest", "Ingest PDFs", HolonKind::SysmlBlock, "pipeline"), + Holon::child( + "classify", + "Classify Transactions", + HolonKind::SysmlBlock, + "pipeline", + ), + ]; + let g = CytoscapeGraph::from_holons(&holons); + let emitted = SysmlV2Emitter::emit(&g); + + let result = validate_sysml_v2(&emitted); + assert!( + result.disposition.is_satisfied(), + "SysmlV2Emitter output failed to parse as SysML v2: {:?}\n---\n{emitted}", + result.disposition + ); +} + +#[test] +fn empty_graph_emits_valid_sysml_v2() { + let g = CytoscapeGraph::from_holons(&[]); + let emitted = SysmlV2Emitter::emit(&g); + let result = validate_sysml_v2(&emitted); + assert!(result.disposition.is_satisfied(), "{:?}", result.disposition); +} diff --git a/crates/sysml-derive/Cargo.toml b/crates/sysml-derive/Cargo.toml new file mode 100644 index 00000000..d3440a77 --- /dev/null +++ b/crates/sysml-derive/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sysml-derive" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Spike: #[derive(SysmlBlock)] walks a struct's fields via its AST (syn) and generates a SysML-v2 block definition at compile time. See docs/systems-modeling-registry-rescope.md (epic part 2, addendum)." + +[lib] +proc-macro = true + +[dependencies] +syn = { workspace = true, features = ["full"] } +quote = { workspace = true } +proc-macro2 = { workspace = true } + +[dev-dependencies] +# Test-only: validates the actual generated sysml_block_def() text against +# a real SysML v2 grammar (ufo_types::sysml, wired to sysml-v2-parser) — +# see tests/real_grammar_validation.rs. Not a runtime dependency of the +# proc-macro itself. +ufo-types = { path = "../ufo-types" } diff --git a/crates/sysml-derive/src/lib.rs b/crates/sysml-derive/src/lib.rs new file mode 100644 index 00000000..4deabe8e --- /dev/null +++ b/crates/sysml-derive/src/lib.rs @@ -0,0 +1,172 @@ +//! `#[derive(SysmlBlock)]` — walks a struct's fields via its AST (`syn`) and +//! generates a `sysml_block_def()` associated function returning the +//! equivalent SysML-v2 `part def` textual definition, computed at compile +//! time from the field list. (The derive and function are named after the +//! informal "block definition" concept, not the literal SysML v1 `Block`/ +//! `block def` keyword — SysML v2 renamed that construct to `part def`; see +//! below.) +//! +//! Spike for the systems-modeling epic — see +//! `docs/systems-modeling-registry-rescope.md` §2a and §6 task 1. This +//! proves the "walk the Rust AST, generate SysML content via macro" +//! direction the user proposed as an alternative/complement to LinkML, +//! before either is wired into real `ArtifactKind`/`NodeType` node types. +//! Not conformance-checked against a real SysML-v2 grammar/parser (Part 1's +//! Tier 0 candidates) end-to-end, but the specific field-type mappings below +//! were checked against SysML v2's `ScalarValues` standard-library package +//! and its textual grammar (ledgrrr#195): +//! +//! - `Vec` -> `T[*]`, `Option` -> `T[0..1]` (multiplicity suffixes — +//! unaffected by the scalar mapping below, applied to the inner `T`). +//! - Rust primitive scalars are mapped to their `ScalarValues` equivalent +//! rather than emitted as bare Rust keywords (`bool`/`usize`/etc. aren't +//! SysML v2 type names and would be dangling references): `bool` -> +//! `ScalarValues::Boolean`; `u8..u128`/`usize` -> `ScalarValues::Natural`; +//! `i8..i128`/`isize` -> `ScalarValues::Integer`; `f32`/`f64` -> +//! `ScalarValues::Rational`. +//! - `chrono::DateTime` (any `Tz`) -> `ScalarValues::String`. SysML v2 +//! has no native date/time scalar, and critically, SysML v2's textual +//! grammar has **no angle-bracket generic-parameter syntax** — before this +//! fix, a `DateTime` field emitted the literal, invalid text +//! `attribute x : DateTime;`, which does not parse under any +//! conformant SysML v2 grammar. The `Vec`/`Option` cases don't have this +//! problem because their generic parameter is consumed into a +//! multiplicity suffix, never rendered as `<...>` text; any other +//! generic type (single type argument, not `Vec`/`Option`/`DateTime`) is +//! therefore rejected as a compile error rather than silently emitting +//! the same class of invalid syntax. +//! - `String` and opaque domain types (e.g. `NodeId`, `Confidence`, +//! `rust_decimal::Decimal`) pass through as bare type-name references, +//! under the standard SysML modeling assumption that they resolve to a +//! sibling `part def`/`attribute def`/`datatype` declared elsewhere in +//! the same model or an imported package — the same assumption every +//! `part def` referencing another `part def` by name already relies on. +//! This is a documented modeling assumption, not a bug: unlike the +//! primitives/`DateTime` case above, there is no single universally-right +//! SysML mapping for a project-specific newtype to invent here. +//! - The outer wrapper emits `part def {Name} { ... }`, not `block def` — +//! SysML v1 called this construct `Block`; SysML v2 renamed the +//! equivalent concept to `part def`, and `block` is not a SysML v2 +//! keyword at all. Confirmed against the real `sysml-v2-parser` crate via +//! `ufo_types::sysml::validate_sysml_v2` (see +//! `crates/sysml-derive/tests/real_grammar_validation.rs`) — the same bug +//! `holon-viz`'s `SysmlV2Emitter` had (ledgrrr#197). +//! +//! Only supports structs with named fields; anything else is a compile +//! error via `syn::Error::to_compile_error`, not a panic. An unsupported +//! generic field type (see above) is likewise a compile error, not a +//! silent invalid-syntax emission. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, parse_macro_input}; + +#[proc_macro_derive(SysmlBlock)] +pub fn derive_sysml_block(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + let named_fields = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(named) => &named.named, + _ => { + return syn::Error::new_spanned( + &input, + "SysmlBlock only supports structs with named fields", + ) + .to_compile_error() + .into(); + } + }, + _ => { + return syn::Error::new_spanned(&input, "SysmlBlock only supports structs") + .to_compile_error() + .into(); + } + }; + + let mut attribute_lines = String::new(); + for field in named_fields { + // Safe: Fields::Named guarantees every field has an ident. + let field_name = field.ident.as_ref().unwrap().to_string(); + let (sysml_type, multiplicity) = match sysml_type_and_multiplicity(&field.ty) { + Ok(pair) => pair, + Err(err) => return err.to_compile_error().into(), + }; + attribute_lines.push_str(&format!( + " attribute {field_name} : {sysml_type}{multiplicity};\n" + )); + } + + let block_def = format!("part def {name} {{\n{attribute_lines}}}\n"); + + let expanded = quote! { + impl #name { + /// SysML-v2 block definition text for this type, generated at + /// compile time by `#[derive(SysmlBlock)]` walking its fields. + pub const fn sysml_block_def() -> &'static str { + #block_def + } + } + }; + + expanded.into() +} + +/// Approximate a Rust field type as a SysML-v2 attribute type + multiplicity +/// suffix: `Vec` -> `(T, "[*]")`, `Option` -> `(T, "[0..1]")`, +/// `DateTime<_>` -> `(ScalarValues::String, "")` (no generic-parameter +/// syntax exists in SysML v2's grammar, so the parameter is dropped, not +/// rendered), everything else -> `(scalar-mapped-or-bare-name, "")`. Any +/// other single-type-argument generic is rejected at compile time rather +/// than silently emitting the same invalid `Outer` text `DateTime` +/// used to produce. +fn sysml_type_and_multiplicity(ty: &Type) -> syn::Result<(String, String)> { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + let ident = segment.ident.to_string(); + if ident == "Vec" || ident == "Option" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(GenericArgument::Type(inner)) = args.args.first() { + let suffix = if ident == "Vec" { "[*]" } else { "[0..1]" }; + return Ok((sysml_scalar_name(inner), suffix.to_string())); + } + } + } + if ident == "DateTime" { + return Ok(("ScalarValues::String".to_string(), String::new())); + } + if matches!(segment.arguments, PathArguments::AngleBracketed(_)) { + return Err(syn::Error::new_spanned( + ty, + format!( + "SysmlBlock has no SysML-v2 mapping for generic type `{ident}<..>` \ + (SysML v2's grammar has no angle-bracket generic syntax); add an \ + explicit case to sysml_type_and_multiplicity in sysml-derive/src/lib.rs" + ), + )); + } + } + } + Ok((sysml_scalar_name(ty), String::new())) +} + +/// Map a Rust primitive scalar to its SysML-v2 `ScalarValues` equivalent; +/// everything else (`String`, and opaque domain types like `NodeId`, +/// `Confidence`, `rust_decimal::Decimal`) passes through as a bare +/// type-name reference, assumed to resolve to a sibling declaration +/// elsewhere in the model. +fn sysml_scalar_name(ty: &Type) -> String { + let raw = type_to_string(ty); + match raw.as_str() { + "bool" => "ScalarValues::Boolean".to_string(), + "u8" | "u16" | "u32" | "u64" | "u128" | "usize" => "ScalarValues::Natural".to_string(), + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" => "ScalarValues::Integer".to_string(), + "f32" | "f64" => "ScalarValues::Rational".to_string(), + _ => raw, + } +} + +fn type_to_string(ty: &Type) -> String { + quote!(#ty).to_string().replace(' ', "") +} diff --git a/crates/sysml-derive/tests/basic.rs b/crates/sysml-derive/tests/basic.rs new file mode 100644 index 00000000..1143a2c0 --- /dev/null +++ b/crates/sysml-derive/tests/basic.rs @@ -0,0 +1,50 @@ +//! Spike proof: derive a SysML-v2 block definition from a struct that +//! mirrors `arc-kit-au::Transaction`'s field shape (name + type, not a real +//! dependency on `arc-kit-au` — wiring the derive onto the real production +//! struct is task 3 in `docs/systems-modeling-registry-rescope.md` §6, once +//! this spike and the LinkML spike have been compared). + +use sysml_derive::SysmlBlock; + +// Mirrors arc-kit-au::node::Transaction's exact field shape. +#[allow(dead_code)] +#[derive(SysmlBlock)] +struct Transaction { + tx_id: String, + account_id: String, + date: String, + amount: String, + description: String, + source_rows: Vec, +} + +// Stand-in for arc-kit-au::node::NodeId — only the type name matters for the +// macro's output, not the real definition. +#[allow(dead_code)] +struct NodeId(String); + +// Mirrors arc-kit-au::node::Classification's Option field, to exercise the +// `Option` -> `T[0..1]` branch alongside Vec's `T[*]`. +#[allow(dead_code)] +#[derive(SysmlBlock)] +struct Classification { + tx_id: String, + category: String, + sub_category: Option, +} + +#[test] +fn emits_block_def_with_scalar_and_vec_attributes() { + let block = Transaction::sysml_block_def(); + assert!(block.starts_with("part def Transaction {\n")); + assert!(block.contains(" attribute tx_id : String;\n")); + assert!(block.contains(" attribute source_rows : NodeId[*];\n")); + assert!(block.ends_with("}\n")); +} + +#[test] +fn emits_optional_attribute_with_zero_to_one_multiplicity() { + let block = Classification::sysml_block_def(); + assert!(block.contains(" attribute sub_category : String[0..1];\n")); + assert!(block.contains(" attribute category : String;\n")); +} diff --git a/crates/sysml-derive/tests/real_grammar_validation.rs b/crates/sysml-derive/tests/real_grammar_validation.rs new file mode 100644 index 00000000..ee59e4d1 --- /dev/null +++ b/crates/sysml-derive/tests/real_grammar_validation.rs @@ -0,0 +1,97 @@ +//! Validates `#[derive(SysmlBlock)]`'s generated `sysml_block_def()` text +//! against a real SysML v2 grammar implementation (`ufo_types::sysml`, +//! wired to the `sysml-v2-parser` crate), instead of only checking "no +//! angle brackets" or other hand-rolled heuristics. +//! +//! This is the concrete validation ledgrrr#195 asked for and the +//! prioritized follow-up after the scalar-type-mapping fix +//! (`fix_datetime_and_primitive_field_types_now_pass_real_parser` below is +//! the test that would have failed before that fix — `DateTime` used +//! to emit literally invalid syntax). + +use sysml_derive::SysmlBlock; +use ufo_types::sysml::validate_sysml_v2; + +struct NodeId(String); +struct Confidence(f64); +struct Decimal(String); +struct Utc; +struct DateTime(T); + +// Mirrors arc-kit-au::node::Transaction (ledgrrr#184's original struct). +#[derive(SysmlBlock)] +struct Transaction { + tx_id: String, + source_rows: Vec, +} + +// Mirrors arc-kit-au::node::Requirement (ledgrrr#184) -- has a `DateTime` field. +#[derive(SysmlBlock)] +struct Requirement { + requirement_id: String, + rationale: Option, + related_decisions: Vec, + imported_at: DateTime, +} + +// Mirrors arc-kit-au::node::ExtractedRow (ledgrrr#193 retrofit) -- Decimal/Confidence. +#[derive(SysmlBlock)] +struct ExtractedRow { + amount: Decimal, + source_document: NodeId, + extraction_confidence: Confidence, +} + +// Mirrors arc-kit-au::node::ModelProposal (ledgrrr#193 retrofit) -- bool + DateTime. +#[derive(SysmlBlock)] +struct ModelProposal { + validated: bool, + proposed_at: DateTime, +} + +// Mirrors arc-kit-au::node::WorkbookRow (ledgrrr#193 retrofit) -- usize. +#[derive(SysmlBlock)] +struct WorkbookRow { + row_index: usize, +} + +fn assert_valid_sysml_v2(label: &str, text: &str) { + let result = validate_sysml_v2(text); + assert!( + result.disposition.is_satisfied(), + "{label}: generated text failed real SysML v2 grammar validation: {:?}\n---\n{text}", + result.disposition + ); +} + +#[test] +fn transaction_block_def_is_valid_sysml_v2() { + assert_valid_sysml_v2("Transaction", Transaction::sysml_block_def()); +} + +#[test] +fn datetime_and_primitive_field_types_now_pass_real_parser() { + // Before the scalar-type-mapping fix, `imported_at : DateTime` + // emitted literally invalid syntax (angle-bracket generics don't exist + // in SysML v2's grammar) -- this would have failed here. + assert_valid_sysml_v2("Requirement", Requirement::sysml_block_def()); +} + +#[test] +fn opaque_domain_types_produce_valid_sysml_v2() { + // Decimal/Confidence/NodeId pass through as bare type-name references + // (documented modeling assumption) -- confirmed that's still + // syntactically valid (referencing an undeclared name is not a syntax + // error in SysML v2, only a semantic one this validator doesn't check). + assert_valid_sysml_v2("ExtractedRow", ExtractedRow::sysml_block_def()); +} + +#[test] +fn bool_and_datetime_field_combination_is_valid_sysml_v2() { + assert_valid_sysml_v2("ModelProposal", ModelProposal::sysml_block_def()); +} + +#[test] +fn usize_field_is_valid_sysml_v2() { + assert_valid_sysml_v2("WorkbookRow", WorkbookRow::sysml_block_def()); +} diff --git a/crates/sysml-derive/tests/retrofitted_field_types.rs b/crates/sysml-derive/tests/retrofitted_field_types.rs new file mode 100644 index 00000000..fdfa43f1 --- /dev/null +++ b/crates/sysml-derive/tests/retrofitted_field_types.rs @@ -0,0 +1,80 @@ +//! Regression coverage for field types introduced by the retrofit of +//! `#[derive(SysmlBlock)]` onto pre-existing `arc-kit-au::node` structs +//! (ledgrrr#193) and the earlier `Requirement`/`Decision`/`Cost` addition +//! (ledgrrr#184) — `rust_decimal::Decimal`, a custom `Confidence` type, +//! `bool`, `usize`, and `chrono::DateTime`. None of these were +//! exercised by `tests/basic.rs` (only `String`/`Option`/ +//! `Vec`), and `DateTime` previously emitted invalid SysML v2 +//! syntax (`attribute x : DateTime;` — SysML v2's grammar has no +//! angle-bracket generic syntax). See ledgrrr#195. + +use sysml_derive::SysmlBlock; + +// Stand-ins — only the type names matter for the macro's output. +#[allow(dead_code)] +struct NodeId(String); +#[allow(dead_code)] +struct Confidence(f64); +#[allow(dead_code)] +struct Decimal(String); +#[allow(dead_code)] +struct Utc; +#[allow(dead_code)] +struct DateTime(T); + +// Mirrors arc-kit-au::node::ExtractedRow. +#[allow(dead_code)] +#[derive(SysmlBlock)] +struct ExtractedRow { + amount: Decimal, + source_document: NodeId, + extraction_confidence: Confidence, +} + +// Mirrors arc-kit-au::node::ModelProposal / OperatorApproval / ValidationIssue. +#[allow(dead_code)] +#[derive(SysmlBlock)] +struct ModelProposal { + validated: bool, + proposed_at: DateTime, +} + +// Mirrors arc-kit-au::node::WorkbookRow. +#[allow(dead_code)] +#[derive(SysmlBlock)] +struct WorkbookRow { + row_index: usize, +} + +#[test] +fn opaque_domain_types_pass_through_as_bare_type_names() { + let block = ExtractedRow::sysml_block_def(); + // Decimal/Confidence/NodeId have no ScalarValues equivalent — they're + // assumed to resolve to sibling declarations elsewhere in the model. + assert!(block.contains(" attribute amount : Decimal;\n")); + assert!(block.contains(" attribute source_document : NodeId;\n")); + assert!(block.contains(" attribute extraction_confidence : Confidence;\n")); +} + +#[test] +fn bool_maps_to_scalar_values_boolean() { + let block = ModelProposal::sysml_block_def(); + assert!(block.contains(" attribute validated : ScalarValues::Boolean;\n")); +} + +#[test] +fn datetime_maps_to_scalar_values_string_never_emits_generic_brackets() { + let block = ModelProposal::sysml_block_def(); + assert!(block.contains(" attribute proposed_at : ScalarValues::String;\n")); + assert!( + !block.contains('<'), + "SysML v2 has no angle-bracket generic syntax; generated block must never contain \ + `<` or `>`:\n{block}" + ); +} + +#[test] +fn usize_maps_to_scalar_values_natural() { + let block = WorkbookRow::sysml_block_def(); + assert!(block.contains(" attribute row_index : ScalarValues::Natural;\n")); +} diff --git a/crates/ufo-types/Cargo.toml b/crates/ufo-types/Cargo.toml index 109566b4..60489958 100644 --- a/crates/ufo-types/Cargo.toml +++ b/crates/ufo-types/Cargo.toml @@ -10,6 +10,11 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true schemars = { version = "0.8", features = ["derive"] } +# Pinned to an exact version (not a caret range): pre-1.0, ~one release +# every 2-3 days. See docs/sysml-v2-parser-spike.md for the crate-health +# writeup. Not wasm32-compatible (pulls in `stacker`), so this must never +# become a dependency of anything in the holon-viz-wasm build graph. +sysml-v2-parser = "=0.54.0" [dev-dependencies] serde_json.workspace = true diff --git a/crates/ufo-types/src/lib.rs b/crates/ufo-types/src/lib.rs index 5f4af255..365be77e 100644 --- a/crates/ufo-types/src/lib.rs +++ b/crates/ufo-types/src/lib.rs @@ -6,8 +6,10 @@ pub mod iso; pub mod satisfies; +pub mod sysml; pub mod ufo; pub use iso::{BankAccount, Currency, FinancialInstrument, Isin, Lei}; pub use satisfies::{Constraint, Disposition, NodeId, SatisfiesResult, Satisfies}; +pub use sysml::{SysmlV2Syntax, validate_sysml_v2}; pub use ufo::{EndurantStereotype, MomentStereotype, PerdurantStereotype, UfoCategory}; diff --git a/crates/ufo-types/src/sysml.rs b/crates/ufo-types/src/sysml.rs new file mode 100644 index 00000000..150f6a0c --- /dev/null +++ b/crates/ufo-types/src/sysml.rs @@ -0,0 +1,96 @@ +//! SysML v2 textual-syntax validation, shared across every crate that +//! generates SysML v2 text (`holon-viz`'s `SysmlV2Emitter`, `sysml-derive`'s +//! `#[derive(SysmlBlock)]`). +//! +//! Wired to `sysml-v2-parser` (`elan8/sysml-v2-parser` on GitHub, +//! `sysml-v2-parser` on crates.io) — a real, if young (0.x, ~5 months old) +//! SysML v2 grammar implementation, not a hand-rolled heuristic. See +//! `docs/sysml-v2-parser-spike.md` in this repo for the crate-health +//! writeup that led to adopting it (verdict: adopt-with-caveats — genuine +//! grammar coverage, but expect upstream gaps beyond basic +//! package/part/attribute constructs). +//! +//! This module intentionally validates *syntax only* (does it parse under +//! SysML v2's grammar), not semantic well-formedness (do referenced types +//! resolve, are multiplicities consistent, etc.) — that's a much larger +//! problem this crate does not attempt to solve. + +use crate::satisfies::{Constraint, NodeId, SatisfiesResult, Satisfies}; +use sysml_v2_parser::parse_for_editor; + +/// Constraint: the subject text is syntactically valid SysML v2, per +/// `sysml-v2-parser`'s resilient-editor-mode parser. +pub struct SysmlV2Syntax; + +impl Constraint for SysmlV2Syntax {} + +impl Satisfies for str { + fn satisfies(&self, _constraint: &SysmlV2Syntax) -> SatisfiesResult { + validate_sysml_v2(self) + } +} + +impl Satisfies for String { + fn satisfies(&self, constraint: &SysmlV2Syntax) -> SatisfiesResult { + self.as_str().satisfies(constraint) + } +} + +/// Parse `text` under SysML v2's grammar (via `parse_for_editor`'s resilient +/// mode — never panics, always returns diagnostics) and report the result as +/// a [`SatisfiesResult`]: `Satisfied` if there are zero diagnostics, +/// `Violated` with the joined diagnostic messages (each with its +/// line/column, when available) otherwise. +pub fn validate_sysml_v2(text: &str) -> SatisfiesResult { + let result = parse_for_editor(text); + if result.is_ok() { + return SatisfiesResult::satisfied(1.0, Vec::::new()); + } + let reason = result + .errors + .iter() + .map(|e| match (e.line, e.column) { + (Some(l), Some(c)) => format!("line {l}, col {c}: {}", e.message), + _ => e.message.clone(), + }) + .collect::>() + .join("; "); + SatisfiesResult::violated(reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_sysml_v2_is_satisfied() { + let text = "package Foo {\n part def Bar {\n attribute x : ScalarValues::Boolean;\n }\n}\n"; + let result = validate_sysml_v2(text); + assert!(result.disposition.is_satisfied(), "{:?}", result.disposition); + } + + #[test] + fn sysml_v1_block_def_keyword_is_rejected() { + // SysML v1 called this construct `Block`; SysML v2 renamed it to + // `part def`. `block def` is not a SysML v2 keyword at all. + let text = "package Foo {\n block def Bar {\n }\n}\n"; + let result = validate_sysml_v2(text); + assert!(!result.disposition.is_satisfied()); + } + + #[test] + fn comment_swallowing_closing_brace_is_rejected() { + // A `//` line comment on the same line as a closing `}` comments the + // brace out, leaving the block unclosed. + let text = "package Foo {\n part def Bar { // note\n}\n"; + let result = validate_sysml_v2(text); + assert!(!result.disposition.is_satisfied()); + } + + #[test] + fn satisfies_trait_is_usable_on_str_and_string() { + let owned = String::from("package Foo {\n}\n"); + assert!(owned.satisfies(&SysmlV2Syntax).disposition.is_satisfied()); + assert!(owned.as_str().satisfies(&SysmlV2Syntax).disposition.is_satisfied()); + } +} diff --git a/ui/docs/public/viz-manifest.json b/ui/docs/public/viz-manifest.json index 11291912..5bdf8ad9 100644 --- a/ui/docs/public/viz-manifest.json +++ b/ui/docs/public/viz-manifest.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.10.0", "objects": [ { "type_name": "PipelineState",