From 7bbad80b1adcc4d64e6a39061253c4475a493695 Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 10:15:02 +0000 Subject: [PATCH 1/6] feat(sysml-derive): spike a Rust-AST-to-SysML-v2 derive macro New crate: #[derive(SysmlBlock)] walks a struct's named fields via syn and generates a sysml_block_def() associated fn returning the equivalent SysML-v2 'block def' text at compile time (matching holon-viz's SysmlV2Emitter 'block def' terminology). Field-type mapping: Vec -> T[*], Option -> T[0..1], everything else -> plain T. Not yet checked against a real SysML-v2 grammar/parser (Part 1's Tier 0 candidates) -- this is the spike from docs/systems-modeling-registry-rescope.md #2a/#6 task 1, proving the AST-walk direction before comparing it against the LinkML spike (task 2) and wiring either into the real ArtifactKind/NodeType widening (task 3). Tests mirror arc-kit-au::Transaction's and ::Classification's field shapes (name+type only, no dependency on arc-kit-au itself -- wiring the derive onto the real production structs is task 3, not this spike) and assert on the emitted block def text for both the Vec and Option multiplicity branches. Same technique this codebase already approved for a different target: AGENTS.md (PM-3, 2026-05-13) sanctions #[derive(specta::Type)] for Rust-to-TypeScript; this is Rust-to-SysML-v2/KerML instead. --- Cargo.lock | 9 +++ Cargo.toml | 4 ++ crates/sysml-derive/Cargo.toml | 14 +++++ crates/sysml-derive/src/lib.rs | 94 ++++++++++++++++++++++++++++++ crates/sysml-derive/tests/basic.rs | 50 ++++++++++++++++ 5 files changed, 171 insertions(+) create mode 100644 crates/sysml-derive/Cargo.toml create mode 100644 crates/sysml-derive/src/lib.rs create mode 100644 crates/sysml-derive/tests/basic.rs diff --git a/Cargo.lock b/Cargo.lock index a812d5d..fe17f24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8732,6 +8732,15 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "sysml-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "system-configuration" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 0c7cfec..da89e32 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/sysml-derive/Cargo.toml b/crates/sysml-derive/Cargo.toml new file mode 100644 index 0000000..c8cc012 --- /dev/null +++ b/crates/sysml-derive/Cargo.toml @@ -0,0 +1,14 @@ +[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 } diff --git a/crates/sysml-derive/src/lib.rs b/crates/sysml-derive/src/lib.rs new file mode 100644 index 0000000..76b7375 --- /dev/null +++ b/crates/sysml-derive/src/lib.rs @@ -0,0 +1,94 @@ +//! `#[derive(SysmlBlock)]` — walks a struct's fields via its AST (`syn`) and +//! generates a `sysml_block_def()` associated function returning the +//! equivalent SysML-v2 `block def` textual definition, computed at compile +//! time from the field list. +//! +//! 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 yet validated against a real SysML-v2 grammar or parser (Part 1's +//! Tier 0 candidates) — the field-type-to-SysML-type mapping below +//! (`Vec` -> `T[*]`, `Option` -> `T[0..1]`) is a reasonable first +//! approximation, not a conformance-checked one. +//! +//! Only supports structs with named fields; anything else is a compile +//! error via `syn::Error::to_compile_error`, not a panic. + +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) = sysml_type_and_multiplicity(&field.ty); + attribute_lines.push_str(&format!( + " attribute {field_name} : {sysml_type}{multiplicity};\n" + )); + } + + let block_def = format!("block 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]")`, +/// everything else -> `(T, "")`. +fn sysml_type_and_multiplicity(ty: &Type) -> (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 (type_to_string(inner), suffix.to_string()); + } + } + } + } + } + (type_to_string(ty), String::new()) +} + +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 0000000..d3d2340 --- /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("block 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")); +} From e15deccd8771bc1cfae14338ed75af0db77d4d6a Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 13:04:48 +0000 Subject: [PATCH 2/6] chore: regenerate viz-manifest.json (version drift 1.9.0 -> 1.10.0) --- ui/docs/public/viz-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/docs/public/viz-manifest.json b/ui/docs/public/viz-manifest.json index 1129191..5bdf8ad 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", From 9c2de8ddc46e96540bb11e238bf682e48e24d02e Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 13:15:23 +0000 Subject: [PATCH 3/6] fix(sysml-derive): map primitive scalars + DateTime to SysML ScalarValues DateTime previously emitted the literal, invalid text `DateTime` as a SysML v2 attribute type -- SysML v2's grammar has no angle-bracket generic-parameter syntax, so this would fail to parse under any conformant SysML v2 tool. bool/usize/etc. also passed through as bare Rust keywords with no corresponding SysML type. Map DateTime<_> -> ScalarValues::String, bool -> ScalarValues::Boolean, unsigned ints -> ScalarValues::Natural, signed ints -> ScalarValues::Integer, floats -> ScalarValues::Rational. Any other single-type-argument generic is now a compile error instead of a silent invalid-syntax emission. Opaque domain types (NodeId, Confidence, Decimal) still pass through as bare names -- documented as an intentional modeling assumption, not a bug. Adds regression tests for the field types introduced by #184/#193 (Decimal, Confidence, bool, usize, DateTime) that basic.rs never exercised. Closes ledgrrr#195. --- crates/sysml-derive/src/lib.rs | 88 ++++++++++++++++--- .../tests/retrofitted_field_types.rs | 80 +++++++++++++++++ 2 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 crates/sysml-derive/tests/retrofitted_field_types.rs diff --git a/crates/sysml-derive/src/lib.rs b/crates/sysml-derive/src/lib.rs index 76b7375..7be062d 100644 --- a/crates/sysml-derive/src/lib.rs +++ b/crates/sysml-derive/src/lib.rs @@ -8,13 +8,44 @@ //! 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 yet validated against a real SysML-v2 grammar or parser (Part 1's -//! Tier 0 candidates) — the field-type-to-SysML-type mapping below -//! (`Vec` -> `T[*]`, `Option` -> `T[0..1]`) is a reasonable first -//! approximation, not a conformance-checked one. +//! 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 `block def`/`attribute def`/`datatype` declared elsewhere in +//! the same model or an imported package — the same assumption every +//! `block def` referencing another `block 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. //! //! Only supports structs with named fields; anything else is a compile -//! error via `syn::Error::to_compile_error`, not a panic. +//! 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; @@ -48,7 +79,10 @@ pub fn derive_sysml_block(input: TokenStream) -> TokenStream { 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) = sysml_type_and_multiplicity(&field.ty); + 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" )); @@ -71,8 +105,13 @@ pub fn derive_sysml_block(input: TokenStream) -> TokenStream { /// Approximate a Rust field type as a SysML-v2 attribute type + multiplicity /// suffix: `Vec` -> `(T, "[*]")`, `Option` -> `(T, "[0..1]")`, -/// everything else -> `(T, "")`. -fn sysml_type_and_multiplicity(ty: &Type) -> (String, String) { +/// `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(); @@ -80,13 +119,42 @@ fn sysml_type_and_multiplicity(ty: &Type) -> (String, String) { 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 (type_to_string(inner), suffix.to_string()); + 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" + ), + )); + } } } - (type_to_string(ty), String::new()) + 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 { 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 0000000..fdfa43f --- /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")); +} From 828a74c6d7261f3c82d494c3afb343a9146cf72a Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 13:41:51 +0000 Subject: [PATCH 4/6] fix(holon-viz): emit valid SysML v2 (part def, not block def); wire real parser validation into ufo-types Ran SysmlV2Emitter's own output through the real sysml-v2-parser crate (docs/sysml-v2-parser-spike.md, an existing unmerged spike) and confirmed two bugs make it non-parseable: - The closing '}' was on the same line as a trailing '//' comment, so the comment swallowed it -- the block was never syntactically closed. - The emitter used SysML v1's 'block def' keyword. SysML v2 renamed this construct to 'part def'; 'block' is not a SysML v2 keyword at all. Fixed both. Added ufo_types::sysml -- a shared Constraint/Satisfies-based SysML v2 syntax validator wired to sysml-v2-parser (pinned to =0.54.0 per the spike's crate-health findings; not wasm32-compatible, so this must stay out of holon-viz's runtime dependency graph -- added to holon-viz only as a dev-dependency, used in a new round-trip test that feeds the emitter's own output through the real parser instead of just asserting on substrings. This is the concrete round-trip-closed signal the existing spike (PR #187) called out as the next step. --- Cargo.lock | 33 ++++++- crates/holon-viz/Cargo.toml | 5 + crates/holon-viz/src/emitter.rs | 15 ++- .../tests/holon_viz_comprehensive.rs | 19 ++-- crates/holon-viz/tests/sysml_v2_roundtrip.rs | 57 +++++++++++ crates/ufo-types/Cargo.toml | 5 + crates/ufo-types/src/lib.rs | 2 + crates/ufo-types/src/sysml.rs | 96 +++++++++++++++++++ 8 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 crates/holon-viz/tests/sysml_v2_roundtrip.rs create mode 100644 crates/ufo-types/src/sysml.rs diff --git a/Cargo.lock b/Cargo.lock index a812d5d..783949e 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,18 @@ dependencies = [ "windows 0.61.3", ] +[[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 +9836,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9927,6 +9957,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "sysml-v2-parser", "thiserror 2.0.18", ] diff --git a/crates/holon-viz/Cargo.toml b/crates/holon-viz/Cargo.toml index 9c34a11..4653543 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 7a69ce7..35093c5 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 df8bf8b..c5a4cd3 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 0000000..549e821 --- /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/ufo-types/Cargo.toml b/crates/ufo-types/Cargo.toml index 109566b..6048995 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 5f4af25..365be77 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 0000000..150f6a0 --- /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()); + } +} From e9483853f70fa8dbdbee10386a9dd3d56fca15cf Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 13:46:23 +0000 Subject: [PATCH 5/6] fix(sysml-derive): emit part def (not block def); validate against real SysML v2 grammar Same bug holon-viz's SysmlV2Emitter had (ledgrrr#197): SysML v1 called this construct Block/'block def'; SysML v2 renamed it to 'part def', and 'block' is not a SysML v2 keyword at all. Confirmed via the newly-wired ufo_types::sysml::validate_sysml_v2 (real sysml-v2-parser crate, not a hand-rolled heuristic) that the fixed output actually parses. Adds tests/real_grammar_validation.rs: runs the actual generated sysml_block_def() text for Transaction/Requirement/ExtractedRow/ ModelProposal/WorkbookRow (mirroring the real production structs from #184/#193) through the real parser. This replaces the 'no angle brackets' manual check used to validate the earlier DateTime/bool/usize scalar mapping fix with genuine grammar validation, closing out ledgrrr#195. --- crates/sysml-derive/Cargo.toml | 7 ++ crates/sysml-derive/src/lib.rs | 20 +++- crates/sysml-derive/tests/basic.rs | 2 +- .../tests/real_grammar_validation.rs | 97 +++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 crates/sysml-derive/tests/real_grammar_validation.rs diff --git a/crates/sysml-derive/Cargo.toml b/crates/sysml-derive/Cargo.toml index c8cc012..d3440a7 100644 --- a/crates/sysml-derive/Cargo.toml +++ b/crates/sysml-derive/Cargo.toml @@ -12,3 +12,10 @@ proc-macro = true 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 index 7be062d..4deabe8 100644 --- a/crates/sysml-derive/src/lib.rs +++ b/crates/sysml-derive/src/lib.rs @@ -1,7 +1,10 @@ //! `#[derive(SysmlBlock)]` — walks a struct's fields via its AST (`syn`) and //! generates a `sysml_block_def()` associated function returning the -//! equivalent SysML-v2 `block def` textual definition, computed at compile -//! time from the field list. +//! 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 @@ -35,12 +38,19 @@ //! - `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 `block def`/`attribute def`/`datatype` declared elsewhere in +//! sibling `part def`/`attribute def`/`datatype` declared elsewhere in //! the same model or an imported package — the same assumption every -//! `block def` referencing another `block def` by name already relies on. +//! `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 @@ -88,7 +98,7 @@ pub fn derive_sysml_block(input: TokenStream) -> TokenStream { )); } - let block_def = format!("block def {name} {{\n{attribute_lines}}}\n"); + let block_def = format!("part def {name} {{\n{attribute_lines}}}\n"); let expanded = quote! { impl #name { diff --git a/crates/sysml-derive/tests/basic.rs b/crates/sysml-derive/tests/basic.rs index d3d2340..1143a2c 100644 --- a/crates/sysml-derive/tests/basic.rs +++ b/crates/sysml-derive/tests/basic.rs @@ -36,7 +36,7 @@ struct Classification { #[test] fn emits_block_def_with_scalar_and_vec_attributes() { let block = Transaction::sysml_block_def(); - assert!(block.starts_with("block def Transaction {\n")); + 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")); 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 0000000..ee59e4d --- /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()); +} From b3e00ea2f9f8c2a3556d26b4919696bc07bf86c0 Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Sat, 22 Aug 2026 14:31:25 +0000 Subject: [PATCH 6/6] chore: regenerate viz-manifest.json (version drift, ledgrrr#194) --- ui/docs/public/viz-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/docs/public/viz-manifest.json b/ui/docs/public/viz-manifest.json index 1129191..5bdf8ad 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",