diff --git a/Cargo.lock b/Cargo.lock index 2d1e949..9e1f3de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7133,6 +7133,18 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqif-mcp-spike" +version = "0.1.0" +dependencies = [ + "arc-kit-au", + "chrono", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -11565,6 +11577,7 @@ dependencies = [ name = "xtask-mcpb" version = "1.10.0" dependencies = [ + "arc-kit-au", "clap", "hex", "ledger-core", diff --git a/Cargo.toml b/Cargo.toml index da89e32..8b0a694 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/beankeeper-bridge", "crates/ledgerr-desktop-agent", "crates/sysml-derive", + "crates/reqif-mcp-spike", ] resolver = "2" diff --git a/crates/arc-kit-au/src/node.rs b/crates/arc-kit-au/src/node.rs index dc93026..81059d2 100644 --- a/crates/arc-kit-au/src/node.rs +++ b/crates/arc-kit-au/src/node.rs @@ -124,7 +124,7 @@ pub fn content_hash(parts: &[&str]) -> String { } /// Source document evidence. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct SourceDoc { /// Original filename (must follow VENDOR--ACCOUNT--YYYY-MM--DOCTYPE.ext) pub filename: String, @@ -151,7 +151,7 @@ impl SourceDoc { } /// Extracted row from document parsing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct ExtractedRow { pub account_id: String, pub date: String, @@ -176,7 +176,7 @@ impl ExtractedRow { } /// Deterministic transaction record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct Transaction { /// Blake3 hash of account/date/amount/description pub tx_id: String, @@ -194,7 +194,7 @@ impl Transaction { } /// Classification applied to transaction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct Classification { pub tx_id: String, pub category: String, @@ -221,7 +221,7 @@ impl Classification { } /// Model-generated classification proposal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct ModelProposal { pub tx_id: String, pub model_name: String, @@ -247,7 +247,7 @@ impl ModelProposal { } /// Operator approval/rejection of model proposal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct OperatorApproval { pub tx_id: String, pub operator_id: String, @@ -275,7 +275,7 @@ impl OperatorApproval { /// Distinct from Classification — validation artifacts represent rule/constraint /// failures, not categorization decisions. PRD-4 Phase 2 requires /// classification_artifact → validation_artifact as a separate chain step. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct ValidationIssue { pub tx_id: String, pub rule: String, @@ -301,7 +301,7 @@ impl ValidationIssue { } /// Final workbook row in CPA export. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, SysmlBlock)] pub struct WorkbookRow { pub tx_id: String, pub sheet_name: String, diff --git a/crates/ledger-core/src/iso.rs b/crates/ledger-core/src/iso.rs index 77cc3cb..cb64638 100644 --- a/crates/ledger-core/src/iso.rs +++ b/crates/ledger-core/src/iso.rs @@ -51,10 +51,16 @@ pub enum ZLayer { Legal, FormalProof, Attestation, + /// SysML-v2 systems-modeling primitives (Requirement/Decision/Cost) — + /// a dedicated layer rather than folded into the (still-unimplemented, + /// see `docs/ontological-implementation-spec.md` §6.1) proposed `Domain` + /// layer, per explicit decision: independent toggle/color in the + /// isometric renderer over reusing an ontological-concepts layer. + SystemsModel, } impl ZLayer { - /// 0-based layer index (max 5). + /// 0-based layer index (max 6). pub fn index(self) -> u8 { match self { ZLayer::Document => 0, @@ -63,6 +69,7 @@ impl ZLayer { ZLayer::Legal => 3, ZLayer::FormalProof => 4, ZLayer::Attestation => 5, + ZLayer::SystemsModel => 6, } } @@ -75,6 +82,7 @@ impl ZLayer { ZLayer::Legal => "#b91c1c", ZLayer::FormalProof => "#0f766e", ZLayer::Attestation => "#b45309", + ZLayer::SystemsModel => "#be185d", } } @@ -87,6 +95,7 @@ impl ZLayer { ZLayer::Legal => 408.0, ZLayer::FormalProof => 544.0, ZLayer::Attestation => 680.0, + ZLayer::SystemsModel => 816.0, } } @@ -99,6 +108,7 @@ impl ZLayer { ZLayer::Legal => "Legal", ZLayer::FormalProof => "FormalProof", ZLayer::Attestation => "Attestation", + ZLayer::SystemsModel => "SystemsModel", } } } @@ -127,6 +137,12 @@ pub enum SemanticType { Issue, Proof, Attestation, + /// SysML-v2 systems-modeling requirement record — see `ZLayer::SystemsModel`. + Requirement, + /// SysML-v2 systems-modeling decision record — see `ZLayer::SystemsModel`. + Decision, + /// SysML-v2 systems-modeling cost record — see `ZLayer::SystemsModel`. + Cost, Unknown, } @@ -145,6 +161,9 @@ impl SemanticType { SemanticType::Issue => "issue", SemanticType::Proof => "proof", SemanticType::Attestation => "attestation", + SemanticType::Requirement => "requirement", + SemanticType::Decision => "decision", + SemanticType::Cost => "cost", SemanticType::Unknown => "unknown", } } @@ -641,17 +660,31 @@ mod tests { ZLayer::Legal, ZLayer::FormalProof, ZLayer::Attestation, + ZLayer::SystemsModel, ]; for layer in all { assert!( - layer.index() <= 5, - "ZLayer::{:?} has index {} > 5", + layer.index() <= 6, + "ZLayer::{:?} has index {} > 6", layer, layer.index() ); } } + #[test] + fn z_layer_systems_model_is_dedicated_not_pipeline() { + // Decision 2: Requirement/Decision/Cost content gets its own layer, + // not folded into an existing one (e.g. Pipeline or a future Domain). + assert_eq!(ZLayer::SystemsModel.index(), 6); + assert_eq!(ZLayer::SystemsModel.label(), "SystemsModel"); + assert_ne!(ZLayer::SystemsModel.color(), ZLayer::Pipeline.color()); + assert_eq!( + ZLayer::SystemsModel.base_z(), + ZLayer::Attestation.base_z() + 136.0 + ); + } + #[test] fn semantic_type_known_name_nonempty() { let all = [ @@ -666,6 +699,9 @@ mod tests { SemanticType::Issue, SemanticType::Proof, SemanticType::Attestation, + SemanticType::Requirement, + SemanticType::Decision, + SemanticType::Cost, SemanticType::Unknown, ]; for st in all { @@ -771,6 +807,7 @@ mod tests { ZLayer::Legal, ZLayer::FormalProof, ZLayer::Attestation, + ZLayer::SystemsModel, ]; for layer in all { assert!(!layer.to_string().is_empty()); @@ -791,6 +828,9 @@ mod tests { SemanticType::Issue, SemanticType::Proof, SemanticType::Attestation, + SemanticType::Requirement, + SemanticType::Decision, + SemanticType::Cost, SemanticType::Unknown, ]; for st in all { diff --git a/crates/ledger-core/src/iso_objects.rs b/crates/ledger-core/src/iso_objects.rs index 0b0fbcf..9c724e3 100644 --- a/crates/ledger-core/src/iso_objects.rs +++ b/crates/ledger-core/src/iso_objects.rs @@ -1,4 +1,4 @@ -//! `HasVisualization` implementations for the 28 domain types that participate +//! `HasVisualization` implementations for the 31 domain types that participate //! in the isometric pipeline view. //! //! Every `impl HasVisualization` added here must also be registered in @@ -469,9 +469,64 @@ let lots = wallet.cost_basis_lots(method); // FIFO | HIFO | ACB"#, } } +// ============================================================================ +// SYSTEMS MODEL — Requirement/Decision/Cost (z=6, SystemsModel layer) +// ============================================================================ + +#[cfg(feature = "arc-kit-au")] +mod systems_model { + use arc_kit_au::node::{Cost, Decision, Requirement}; + + use crate::iso::{HasVisualization, RhaiDsl, SemanticType, VisualizationSpec, ZLayer}; + + impl HasVisualization for Requirement { + fn viz_spec() -> VisualizationSpec { + VisualizationSpec { + semantic_type: SemanticType::Requirement, + z_layer: ZLayer::SystemsModel, + rhai_dsl: RhaiDsl::new( + r#"let req = load_requirement(source); +if req.status == "active" { link_decisions(req.related_decisions) }"#, + ), + description: "SysML v2 systems-modeling requirement — traceable spec record, linked to the decisions it constrains", + } + } + } + + impl HasVisualization for Decision { + fn viz_spec() -> VisualizationSpec { + VisualizationSpec { + semantic_type: SemanticType::Decision, + z_layer: ZLayer::SystemsModel, + rhai_dsl: RhaiDsl::new( + r#"let decision = record_decision(subject, rationale, decided_by); +link_requirements(decision.related_requirements);"#, + ), + description: "Version-controlled decision record — rationale + decider, linked back to the requirements it satisfies", + } + } + } + + impl HasVisualization for Cost { + fn viz_spec() -> VisualizationSpec { + VisualizationSpec { + semantic_type: SemanticType::Cost, + z_layer: ZLayer::SystemsModel, + rhai_dsl: RhaiDsl::new( + r#"let cost = record_cost(subject, amount, currency); +if cost.related_decision.is_some() { attribute_to_decision(cost) }"#, + ), + description: "Version-controlled cost record — monetary amount attributed to a decision for systems-modeling cost traceability", + } + } + } +} + #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "arc-kit-au")] + use arc_kit_au::node::{Cost, Decision, Requirement}; #[test] fn all_viz_spec_rhai_dsl_has_valid_syntax() { @@ -517,6 +572,13 @@ mod tests { check!(UsRdcCredit); check!(CryptoTx); check!(CryptoWallet); + // Systems-modeling domain + #[cfg(feature = "arc-kit-au")] + { + check!(Requirement); + check!(Decision); + check!(Cost); + } } } diff --git a/crates/ledgerr-mcp/src/contract.rs b/crates/ledgerr-mcp/src/contract.rs index 0818bf1..e77ebea 100644 --- a/crates/ledgerr-mcp/src/contract.rs +++ b/crates/ledgerr-mcp/src/contract.rs @@ -166,13 +166,16 @@ pub const PUBLISHED_TOOLS: [ToolContractSpec; 12] = [ }, ToolContractSpec { name: EVIDENCE_TOOL, - purpose: "evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries", + purpose: "evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries; requirement/decision/cost recording", actions: &[ "provenance_gaps", "trace_tx", "summary", "list_nodes", "node_detail", + "import_requirement", + "record_decision", + "record_cost", ], }, ToolContractSpec { @@ -915,6 +918,46 @@ pub enum EvidenceArgs { }, #[serde(rename = "node_detail")] NodeDetail { node_id: String }, + /// Import a requirement record (e.g. from `reqif-mcp-spike`'s + /// `RequirementRecord`) as a `NodeType::Requirement` evidence node. + #[serde(rename = "import_requirement")] + ImportRequirement { + requirement_id: String, + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + rationale: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + #[serde(default = "default_requirement_status")] + status: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + related_decisions: Vec, + }, + /// Record a decision as a `NodeType::Decision` evidence node. + #[serde(rename = "record_decision")] + RecordDecision { + decision_id: String, + subject: String, + rationale: String, + decided_by: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + related_requirements: Vec, + }, + /// Record a cost as a `NodeType::Cost` evidence node. + #[serde(rename = "record_cost")] + RecordCost { + cost_id: String, + subject: String, + amount: String, + currency: String, + recorded_by: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + related_decision: Option, + }, +} + +fn default_requirement_status() -> String { + "active".to_string() } pub fn parse_evidence(arguments: &Value) -> Result { diff --git a/crates/ledgerr-mcp/src/mcp_adapter.rs b/crates/ledgerr-mcp/src/mcp_adapter.rs index d0cfe04..d57ece8 100644 --- a/crates/ledgerr-mcp/src/mcp_adapter.rs +++ b/crates/ledgerr-mcp/src/mcp_adapter.rs @@ -3223,6 +3223,11 @@ fn parse_evidence_node_type(s: &str) -> Option { "operator_approval" | "approval" => NodeType::OperatorApproval, "workbook_row" | "wb" => NodeType::WorkbookRow, "validation_issue" | "vi" => NodeType::ValidationIssue, + "rnd_activity" | "rnd" => NodeType::RndActivity, + "tax_offset" | "tax" => NodeType::TaxOffset, + "requirement" | "req" => NodeType::Requirement, + "decision" | "dec" => NodeType::Decision, + "cost" => NodeType::Cost, _ => return None, }) } @@ -3359,6 +3364,9 @@ pub fn handle_evidence_tool(service: &TurboLedgerService, arguments: &Value) -> "operator_approvals": counts.get("operator_approval").copied().unwrap_or(0), "workbook_rows": counts.get("workbook_row").copied().unwrap_or(0), "validation_issues": counts.get("validation_issue").copied().unwrap_or(0), + "requirements": counts.get("requirement").copied().unwrap_or(0), + "decisions": counts.get("decision").copied().unwrap_or(0), + "costs": counts.get("cost").copied().unwrap_or(0), }); let wq = evidence.work_queue_summary(); json!({ @@ -3396,7 +3404,8 @@ pub fn handle_evidence_tool(service: &TurboLedgerService, arguments: &Value) -> "error": format!( "Unknown node type: {nt}. Valid types: \ source_doc, extracted_row, transaction, classification, \ - model_proposal, operator_approval, workbook_row, validation_issue" + model_proposal, operator_approval, workbook_row, validation_issue, \ + rnd_activity, tax_offset, requirement, decision, cost" ), }))], "isError": true, @@ -3452,5 +3461,134 @@ pub fn handle_evidence_tool(service: &TurboLedgerService, arguments: &Value) -> }), } } + EvidenceArgs::ImportRequirement { + requirement_id, + title, + rationale, + source, + status, + related_decisions, + } => { + let mut evidence = match service.evidence.lock() { + Ok(e) => e, + Err(_) => { + return error_envelope(&ToolError::Internal( + "evidence mutex poisoned".to_string(), + )) + } + }; + let node = arc_kit_au::node::Requirement { + requirement_id, + title, + rationale, + source, + status, + related_decisions: related_decisions.into_iter().map(arc_kit_au::NodeId).collect(), + imported_at: chrono::Utc::now(), + }; + let node_id = node.node_id(); + match evidence.add_node(arc_kit_au::EvidenceNode::Requirement(node)) { + Ok(id) | Err(arc_kit_au::graph::GraphError::DuplicateNode(id)) => json!({ + "content": [text_content(json!({ + "action": "import_requirement", + "node_id": id.to_string(), + }))], + "isError": false + }), + Err(err) => json!({ + "content": [text_content(json!({ + "action": "import_requirement", + "node_id": node_id.to_string(), + "error": err.to_string(), + }))], + "isError": true, + }), + } + } + EvidenceArgs::RecordDecision { + decision_id, + subject, + rationale, + decided_by, + related_requirements, + } => { + let mut evidence = match service.evidence.lock() { + Ok(e) => e, + Err(_) => { + return error_envelope(&ToolError::Internal( + "evidence mutex poisoned".to_string(), + )) + } + }; + let node = arc_kit_au::node::Decision { + decision_id, + subject, + rationale, + decided_by, + decided_at: chrono::Utc::now(), + related_requirements: related_requirements + .into_iter() + .map(arc_kit_au::NodeId) + .collect(), + }; + match evidence.add_node(arc_kit_au::EvidenceNode::Decision(node)) { + Ok(id) | Err(arc_kit_au::graph::GraphError::DuplicateNode(id)) => json!({ + "content": [text_content(json!({ + "action": "record_decision", + "node_id": id.to_string(), + }))], + "isError": false + }), + Err(err) => json!({ + "content": [text_content(json!({ + "action": "record_decision", + "error": err.to_string(), + }))], + "isError": true, + }), + } + } + EvidenceArgs::RecordCost { + cost_id, + subject, + amount, + currency, + recorded_by, + related_decision, + } => { + let mut evidence = match service.evidence.lock() { + Ok(e) => e, + Err(_) => { + return error_envelope(&ToolError::Internal( + "evidence mutex poisoned".to_string(), + )) + } + }; + let node = arc_kit_au::node::Cost { + cost_id, + subject, + amount, + currency, + recorded_by, + recorded_at: chrono::Utc::now(), + related_decision: related_decision.map(arc_kit_au::NodeId), + }; + match evidence.add_node(arc_kit_au::EvidenceNode::Cost(node)) { + Ok(id) | Err(arc_kit_au::graph::GraphError::DuplicateNode(id)) => json!({ + "content": [text_content(json!({ + "action": "record_cost", + "node_id": id.to_string(), + }))], + "isError": false + }), + Err(err) => json!({ + "content": [text_content(json!({ + "action": "record_cost", + "error": err.to_string(), + }))], + "isError": true, + }), + } + } } } diff --git a/crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs b/crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs new file mode 100644 index 0000000..6fd8f00 --- /dev/null +++ b/crates/ledgerr-mcp/tests/evidence_requirement_decision_cost.rs @@ -0,0 +1,142 @@ +//! Task 6 (`docs/systems-modeling-registry-rescope.md` §6): wires +//! Requirement/Decision/Cost into the `ledgerr_evidence` MCP tool's +//! contract. Covers the new `import_requirement`/`record_decision`/ +//! `record_cost` actions, the `list_nodes` filter extension, and the +//! `summary` node-count extension. + +mod common; + +use ledgerr_mcp::mcp_adapter::handle_evidence_tool; +use ledgerr_mcp::TurboLedgerService; +use serde_json::json; + +fn service() -> TurboLedgerService { + let workbook_path = common::unique_workbook_path("evidence-rdc"); + TurboLedgerService::from_manifest_str(&common::manifest_for_workbook(&workbook_path, 2023)) + .expect("manifest") +} + +#[test] +fn import_requirement_then_list_nodes_and_node_detail() { + let svc = service(); + + let import = handle_evidence_tool( + &svc, + &json!({ + "action": "import_requirement", + "requirement_id": "PO-3-1", + "title": "toolchain risk mitigation", + "rationale": "Specify which tools mitigate identified risks.", + "source": "NIST SSDF 1.1", + "status": "active", + }), + ); + assert_eq!(import["isError"], json!(false)); + let node_id = import["content"][0]["text"] + .as_str() + .and_then(|t| serde_json::from_str::(t).ok()) + .expect("parsed content")["node_id"] + .as_str() + .expect("node_id") + .to_string(); + assert!(node_id.starts_with("req:"), "got {node_id}"); + + // Re-importing the identical requirement is idempotent (content-hash dedup), not an error. + let reimport = handle_evidence_tool( + &svc, + &json!({ + "action": "import_requirement", + "requirement_id": "PO-3-1", + "title": "toolchain risk mitigation", + "rationale": "Specify which tools mitigate identified risks.", + "source": "NIST SSDF 1.1", + "status": "active", + }), + ); + assert_eq!(reimport["isError"], json!(false)); + + let list = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "requirement" }), + ); + let text = list["content"][0]["text"].as_str().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["count"], json!(1), "re-import must not duplicate the node"); + + let detail = handle_evidence_tool( + &svc, + &json!({ "action": "node_detail", "node_id": node_id }), + ); + assert_eq!(detail["isError"], json!(false)); + + let summary = handle_evidence_tool(&svc, &json!({ "action": "summary" })); + let summary_text = summary["content"][0]["text"].as_str().unwrap(); + let summary_parsed: serde_json::Value = serde_json::from_str(summary_text).unwrap(); + assert_eq!(summary_parsed["node_counts"]["requirements"], json!(1)); +} + +#[test] +fn record_decision_and_record_cost_are_content_hashed_and_queryable() { + let svc = service(); + + let decision = handle_evidence_tool( + &svc, + &json!({ + "action": "record_decision", + "decision_id": "DEC-001", + "subject": "Adopt sysml-derive over LinkML", + "rationale": "Rust-first fit, no correctness surprise", + "decided_by": "brianh", + }), + ); + assert_eq!(decision["isError"], json!(false)); + + let cost = handle_evidence_tool( + &svc, + &json!({ + "action": "record_cost", + "cost_id": "COST-001", + "subject": "GPU training run", + "amount": "42.50", + "currency": "USD", + "recorded_by": "brianh", + }), + ); + assert_eq!(cost["isError"], json!(false)); + + let list_dec = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "decision" }), + ); + let dec_text = list_dec["content"][0]["text"].as_str().unwrap(); + let dec_parsed: serde_json::Value = serde_json::from_str(dec_text).unwrap(); + assert_eq!(dec_parsed["count"], json!(1)); + + let list_cost = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "cost" }), + ); + let cost_text = list_cost["content"][0]["text"].as_str().unwrap(); + let cost_parsed: serde_json::Value = serde_json::from_str(cost_text).unwrap(); + assert_eq!(cost_parsed["count"], json!(1)); + + let summary = handle_evidence_tool(&svc, &json!({ "action": "summary" })); + let summary_text = summary["content"][0]["text"].as_str().unwrap(); + let summary_parsed: serde_json::Value = serde_json::from_str(summary_text).unwrap(); + assert_eq!(summary_parsed["node_counts"]["decisions"], json!(1)); + assert_eq!(summary_parsed["node_counts"]["costs"], json!(1)); +} + +#[test] +fn list_nodes_rejects_unknown_type_with_full_valid_list() { + let svc = service(); + let result = handle_evidence_tool( + &svc, + &json!({ "action": "list_nodes", "node_type": "bogus" }), + ); + assert_eq!(result["isError"], json!(true)); + let text = result["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("requirement")); + assert!(text.contains("decision")); + assert!(text.contains("cost")); +} diff --git a/crates/ledgerr-mcp/tests/pipeline_e2e.rs b/crates/ledgerr-mcp/tests/pipeline_e2e.rs index a7aa017..333f92d 100644 --- a/crates/ledgerr-mcp/tests/pipeline_e2e.rs +++ b/crates/ledgerr-mcp/tests/pipeline_e2e.rs @@ -180,7 +180,7 @@ fn pipe_viz_manifest_entry_count_matches_registered_types() { // `impl HasVisualization` blocks in `ledger_core::iso_objects`. It must be // regenerated (and this count updated) whenever an impl is added, removed, // or registered/deregistered in xtask/src/viz_manifest.rs. - const EXPECTED_ENTRY_COUNT: usize = 28; + const EXPECTED_ENTRY_COUNT: usize = 31; let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -218,6 +218,9 @@ fn pipe_viz_manifest_entry_count_matches_registered_types() { "AuRdActivity", "UsRdcCredit", "CryptoWallet", + "Requirement", + "Decision", + "Cost", ] { assert!(names.contains(&expected), "missing {expected} entry"); } diff --git a/crates/reqif-mcp-spike/Cargo.toml b/crates/reqif-mcp-spike/Cargo.toml new file mode 100644 index 0000000..a795630 --- /dev/null +++ b/crates/reqif-mcp-spike/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "reqif-mcp-spike" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Spike: a throwaway Rust client for PromptExecution/reqif-opa-mcp's MCP Streamable-HTTP tools, plus a converter into arc-kit-au::Requirement. See docs/systems-modeling-registry-rescope.md (epic part 2, §6 task 5)." + +[dependencies] +arc-kit-au = { path = "../arc-kit-au" } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] diff --git a/crates/reqif-mcp-spike/src/lib.rs b/crates/reqif-mcp-spike/src/lib.rs new file mode 100644 index 0000000..0417f67 --- /dev/null +++ b/crates/reqif-mcp-spike/src/lib.rs @@ -0,0 +1,329 @@ +//! Spike: a minimal Rust client for `PromptExecution/reqif-opa-mcp`'s MCP +//! Streamable-HTTP server, plus a converter from its `RequirementRecord` +//! shape into `arc_kit_au::Requirement`. +//! +//! Decision 6 (`docs/systems-modeling-registry-rescope.md` §5): reqif-opa-mcp +//! is wrapped over MCP, not ported to Rust — `arc-kit-au` stays the canonical +//! decision+cost ledger, this crate only supplies parsed requirements into it. +//! +//! Protocol notes (reverse-engineered against a live server, FastMCP 3.0.0b1, +//! protocol version 2024-11-05, 2026-08-22): +//! - Every request is `POST {base_url}/mcp` with +//! `Accept: application/json, text/event-stream`. +//! - The response is a single SSE frame (`event: message\ndata: {json}\n\n`), +//! not a long-lived stream — read the whole body and extract the `data:` +//! line. +//! - `initialize` returns an `mcp-session-id` response header that must be +//! echoed on every subsequent request. +//! - A `notifications/initialized` notification (no `id`, no response body) +//! must be sent once after `initialize` before any `tools/*` call. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use arc_kit_au::node::Requirement; + +#[derive(Debug, thiserror::Error)] +pub enum McpClientError { + #[error("HTTP transport error: {0}")] + Transport(#[from] reqwest::Error), + #[error("server response had no SSE `data:` line: {0}")] + NoDataLine(String), + #[error("malformed JSON-RPC response: {0}")] + MalformedJson(#[from] serde_json::Error), + #[error("server did not return an mcp-session-id header on initialize")] + NoSessionId, + #[error("JSON-RPC error response: {0}")] + RpcError(String), + #[error("tool call reported isError=true: {0}")] + ToolError(String), +} + +/// Blocking client for one MCP Streamable-HTTP server instance. +pub struct McpHttpClient { + http: reqwest::blocking::Client, + base_url: String, + session_id: Option, + next_id: u64, +} + +impl McpHttpClient { + pub fn new(base_url: impl Into) -> Self { + Self { + http: reqwest::blocking::Client::new(), + base_url: base_url.into(), + session_id: None, + next_id: 1, + } + } + + fn alloc_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } + + /// Perform the MCP `initialize` handshake and send the mandatory + /// `notifications/initialized` follow-up. + pub fn initialize(&mut self, client_name: &str, client_version: &str) -> Result { + let id = self.alloc_id(); + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": client_name, "version": client_version } + } + }); + + let resp = self + .http + .post(format!("{}/mcp", self.base_url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .json(&body) + .send()?; + + let session_id = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .ok_or(McpClientError::NoSessionId)?; + self.session_id = Some(session_id); + + let text = resp.text()?; + let result = extract_rpc_result(&text)?; + + // Mandatory notification, no response body expected. + let notif = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + self.http + .post(format!("{}/mcp", self.base_url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("mcp-session-id", self.session_id.as_ref().unwrap()) + .json(¬if) + .send()?; + + Ok(result) + } + + /// Call one MCP tool by name, returning its parsed `structuredContent` + /// (falling back to parsing the first text content block as JSON). + pub fn call_tool(&mut self, name: &str, arguments: Value) -> Result { + let id = self.alloc_id(); + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }); + + let mut req = self + .http + .post(format!("{}/mcp", self.base_url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .json(&body); + if let Some(sid) = &self.session_id { + req = req.header("mcp-session-id", sid); + } + + let text = req.send()?.text()?; + let result = extract_rpc_result(&text)?; + + if result.get("isError").and_then(Value::as_bool) == Some(true) { + return Err(McpClientError::ToolError(result.to_string())); + } + + if let Some(structured) = result.get("structuredContent") { + return Ok(structured.clone()); + } + // Fall back to the first text content block (export_req_set returns + // its payload as a JSON string inside `content`, not `structuredContent`). + if let Some(text_block) = result + .get("content") + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()) + .and_then(|b| b.get("text")) + .and_then(Value::as_str) + { + return Ok(serde_json::from_str(text_block)?); + } + Ok(result) + } +} + +/// Extract the JSON-RPC `result` object from a single SSE frame response body. +fn extract_rpc_result(sse_body: &str) -> Result { + let data_line = sse_body + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .ok_or_else(|| McpClientError::NoDataLine(sse_body.to_string()))?; + + let envelope: Value = serde_json::from_str(data_line)?; + if let Some(err) = envelope.get("error") { + return Err(McpClientError::RpcError(err.to_string())); + } + envelope + .get("result") + .cloned() + .ok_or_else(|| McpClientError::NoDataLine(sse_body.to_string())) +} + +/// Mirrors `schemas/requirement-record.schema.json` in reqif-opa-mcp. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequirementRecord { + pub uid: String, + pub key: String, + pub subtypes: Vec, + pub status: String, + pub policy_baseline: PolicyBaselineRef, + #[serde(default)] + pub rubrics: Vec, + pub text: String, + #[serde(default)] + pub attrs: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyBaselineRef { + pub id: String, + pub version: String, + #[serde(default)] + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rubric { + pub engine: String, + pub bundle: String, + pub package: String, + pub rule: String, +} + +/// Convert one reqif-opa-mcp `RequirementRecord` into an `arc-kit-au` +/// `Requirement` node (the `ArtifactKind::Requirement` / `NodeType::Requirement` +/// widening from ledgrrr#184). +/// +/// Field mapping (no direct 1:1 — reqif-opa-mcp's schema predates and is +/// independent of arc-kit-au's node model): +/// - `requirement_id` <- `uid` (globally unique across baselines; `key` is +/// only unique within one standard, e.g. "PO-3-1") +/// - `title` <- `key` (the human-readable requirement key) +/// - `rationale` <- `text` (the full requirement statement doubles as its +/// rationale — reqif-opa-mcp has no separate rationale field) +/// - `source` <- `attrs.source_standard` + `attrs.source_url` if present, +/// else falls back to the policy baseline id +/// - `status` <- `status` (already one of active/obsolete/draft, a superset- +/// compatible vocabulary with arc-kit-au's free-form `String`) +/// - `related_decisions` <- always empty; reqif-opa-mcp carries no decision +/// links, those are created later in arc-kit-au itself +/// - `imported_at` <- caller-supplied (usually `Utc::now()` at conversion time) +pub fn requirement_record_to_node(rec: &RequirementRecord, imported_at: DateTime) -> Requirement { + let source = match (rec.attrs.get("source_standard"), rec.attrs.get("source_url")) { + (Some(std), Some(url)) => Some(format!( + "{} ({})", + std.as_str().unwrap_or_default(), + url.as_str().unwrap_or_default() + )), + (Some(std), None) => Some(std.as_str().unwrap_or_default().to_string()), + _ => Some(rec.policy_baseline.id.clone()), + }; + + Requirement { + requirement_id: rec.uid.clone(), + title: rec.key.clone(), + rationale: Some(rec.text.clone()), + source, + status: rec.status.clone(), + related_decisions: Vec::new(), + imported_at, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record() -> RequirementRecord { + let mut attrs = HashMap::new(); + attrs.insert("severity".to_string(), json!("high")); + attrs.insert("source_standard".to_string(), json!("NIST SSDF 1.1 (SP 800-218)")); + attrs.insert("source_url".to_string(), json!("https://doi.org/10.6028/NIST.SP.800-218")); + + RequirementRecord { + uid: "REQ-NIST-SSDF-002".to_string(), + key: "PW-7-2".to_string(), + subtypes: vec!["SECURE_SDLC".to_string()], + status: "active".to_string(), + policy_baseline: PolicyBaselineRef { + id: "nist-ssdf".to_string(), + version: "2026.01".to_string(), + hash: "e86ec0846e64074e".to_string(), + }, + rubrics: vec![Rubric { + engine: "opa".to_string(), + bundle: "org/compliance".to_string(), + package: "compliance.secure.sdlc".to_string(), + rule: "decision".to_string(), + }], + text: "Perform the code review and/or code analysis...".to_string(), + attrs, + } + } + + #[test] + fn converts_requirement_record_field_shape() { + let rec = sample_record(); + let now = Utc::now(); + let node = requirement_record_to_node(&rec, now); + + assert_eq!(node.requirement_id, "REQ-NIST-SSDF-002"); + assert_eq!(node.title, "PW-7-2"); + assert_eq!(node.rationale.as_deref(), Some(rec.text.as_str())); + assert_eq!( + node.source.as_deref(), + Some("NIST SSDF 1.1 (SP 800-218) (https://doi.org/10.6028/NIST.SP.800-218)") + ); + assert_eq!(node.status, "active"); + assert!(node.related_decisions.is_empty()); + assert_eq!(node.imported_at, now); + } + + #[test] + fn falls_back_to_policy_baseline_id_when_no_source_attrs() { + let mut rec = sample_record(); + rec.attrs.clear(); + let node = requirement_record_to_node(&rec, Utc::now()); + assert_eq!(node.source.as_deref(), Some("nist-ssdf")); + } + + #[test] + fn node_id_is_deterministic_for_converted_requirement() { + let rec = sample_record(); + let now = Utc::now(); + let a = requirement_record_to_node(&rec, now); + let b = requirement_record_to_node(&rec, now); + assert_eq!(a.node_id(), b.node_id()); + } + + #[test] + fn extract_rpc_result_parses_single_sse_frame() { + let sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let result = extract_rpc_result(sse).unwrap(); + assert_eq!(result["ok"], json!(true)); + } + + #[test] + fn extract_rpc_result_surfaces_json_rpc_errors() { + let sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"boom\"}}\n\n"; + let err = extract_rpc_result(sse).unwrap_err(); + assert!(matches!(err, McpClientError::RpcError(_))); + } +} diff --git a/crates/reqif-mcp-spike/tests/live_server.rs b/crates/reqif-mcp-spike/tests/live_server.rs new file mode 100644 index 0000000..a6441d3 --- /dev/null +++ b/crates/reqif-mcp-spike/tests/live_server.rs @@ -0,0 +1,102 @@ +//! End-to-end proof against a real, running `reqif-opa-mcp` server. +//! +//! `#[ignore]`d by default: it requires a checkout of +//! `PromptExecution/reqif-opa-mcp` with its Python deps installed +//! (`uv sync --extra ingest-lite`) and a server already started via +//! `just serve ` (or `uv run python -m reqif_mcp --http --port `), +//! neither of which CI or a fresh clone of this repo has. Run manually: +//! +//! ```sh +//! REQIF_MCP_URL=http://localhost:8123 cargo test -p reqif-mcp-spike \ +//! --test live_server -- --ignored --nocapture +//! ``` +//! +//! Verified working end-to-end 2026-08-22 against `nist_ssdf_dogfood.reqif` +//! and `owasp_asvs_cwe.reqif` from reqif-opa-mcp's own +//! `samples/standards/derived/`. + +use std::{env, fs}; + +use chrono::Utc; +use reqif_mcp_spike::{requirement_record_to_node, McpHttpClient, RequirementRecord}; +use serde_json::json; + +#[test] +#[ignore = "requires a live reqif-opa-mcp server; see module docs"] +fn parses_and_converts_nist_ssdf_sample() { + let base_url = env::var("REQIF_MCP_URL").expect("set REQIF_MCP_URL to a running reqif-opa-mcp server"); + let reqif_path = env::var("REQIF_SAMPLE_PATH") + .unwrap_or_else(|_| "samples/standards/derived/nist_ssdf_dogfood.reqif".to_string()); + + let xml = fs::read_to_string(&reqif_path) + .unwrap_or_else(|e| panic!("could not read {reqif_path}: {e}")); + let xml_b64 = base64_encode(xml.as_bytes()); + + let mut client = McpHttpClient::new(base_url); + client + .initialize("reqif-mcp-spike", env!("CARGO_PKG_VERSION")) + .expect("initialize handshake"); + + let parsed = client + .call_tool( + "reqif_parse", + json!({ + "xml_b64": xml_b64, + "policy_baseline_id": "nist-ssdf", + "policy_baseline_version": "2026.01", + }), + ) + .expect("reqif_parse"); + let handle = parsed["handle"].as_str().expect("handle field").to_string(); + let requirement_count = parsed["requirement_count"].as_u64().expect("requirement_count"); + assert!(requirement_count > 0, "expected at least one requirement"); + + let queried = client + .call_tool("reqif_query", json!({ "handle": handle })) + .expect("reqif_query"); + let requirements: Vec = + serde_json::from_value(queried["requirements"].clone()).expect("deserialize requirement records"); + assert_eq!(requirements.len() as u64, requirement_count); + + let now = Utc::now(); + let nodes: Vec<_> = requirements + .iter() + .map(|rec| requirement_record_to_node(rec, now)) + .collect(); + + for (rec, node) in requirements.iter().zip(nodes.iter()) { + assert_eq!(node.requirement_id, rec.uid); + assert_eq!(node.title, rec.key); + assert_eq!(node.status, rec.status); + println!( + "{} -> ArtifactKind::Requirement node_id={:?}", + rec.key, + node.node_id() + ); + } +} + +/// Tiny inline base64 encoder so this test doesn't need a new dependency +/// just for a one-shot request payload. +fn base64_encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied(); + let b2 = chunk.get(2).copied(); + out.push(ALPHABET[(b0 >> 2) as usize] as char); + out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char); + if let Some(b1) = b1 { + out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char); + } else { + out.push('='); + } + if let Some(b2) = b2 { + out.push(ALPHABET[(b2 & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + out +} diff --git a/docs/mcp-capability-contract.md b/docs/mcp-capability-contract.md index 7e3218e..dddcfe1 100644 --- a/docs/mcp-capability-contract.md +++ b/docs/mcp-capability-contract.md @@ -19,7 +19,7 @@ The default catalog is intentionally small: 12 top-level `ledgerr_*` tools. Each | `ledgerr_ontology` | ontology query/export/write operations | `query_path`, `export_snapshot`, `upsert_entities`, `upsert_edges` | | `ledgerr_xero` | Xero accounting integration: contacts, accounts, bank accounts, entity linking | `get_auth_url`, `exchange_code`, `fetch_contacts`, `search_contacts`, `fetch_accounts`, `fetch_bank_accounts`, `fetch_invoices`, `link_entity`, `sync_catalog` | | `ledgerr_focus` | FOCUS (FinOps Cost Usage Spec) v1.3 cost/usage records, FocusDelta comparison, experiment scoring | `append_focus_record`, `query_focus_summary`, `compute_focus_delta`, `experiment_score` | -| `ledgerr_evidence` | evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries | `provenance_gaps`, `trace_tx`, `summary`, `list_nodes`, `node_detail` | +| `ledgerr_evidence` | evidence traceability: provenance gaps, transaction lineage, review badges, graph summary and node queries; requirement/decision/cost recording | `provenance_gaps`, `trace_tx`, `summary`, `list_nodes`, `node_detail`, `import_requirement`, `record_decision`, `record_cost` | | `ledgerr_schema` | runtime schema extensibility: register, list, remove, and inspect custom entity kinds | `list_kinds`, `register_kind`, `remove_kind`, `get_kind` | | `ledgerr_manifest` | returns the full canonical viz-manifest: mapping of type IDs to their canonical Rhai DSL source strings | `get_manifest` | diff --git a/ui/docs/public/viz-manifest.json b/ui/docs/public/viz-manifest.json index 5bdf8ad..212ac67 100644 --- a/ui/docs/public/viz-manifest.json +++ b/ui/docs/public/viz-manifest.json @@ -3188,6 +3188,293 @@ }, "description": "Crypto wallet — tracks asset lots, cost basis methods, and balance history for tax event attribution" } + }, + { + "type_name": "Requirement", + "spec": { + "semantic_type": "requirement", + "z_layer": "SystemsModel", + "rhai_dsl": { + "source": "let req = load_requirement(source);\nif req.status == \"active\" { link_decisions(req.related_decisions) }", + "symbols": [ + { + "kind": "Keyword", + "name": "let", + "span": { + "line": 1, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "req", + "span": { + "line": 1, + "col": 5 + } + }, + { + "kind": "FunctionCall", + "name": "load_requirement", + "span": { + "line": 1, + "col": 11 + } + }, + { + "kind": "Variable", + "name": "source", + "span": { + "line": 1, + "col": 28 + } + }, + { + "kind": "Keyword", + "name": "if", + "span": { + "line": 2, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "req", + "span": { + "line": 2, + "col": 4 + } + }, + { + "kind": "Variable", + "name": "status", + "span": { + "line": 2, + "col": 8 + } + }, + { + "kind": "FunctionCall", + "name": "link_decisions", + "span": { + "line": 2, + "col": 29 + } + }, + { + "kind": "Variable", + "name": "req", + "span": { + "line": 2, + "col": 44 + } + }, + { + "kind": "Variable", + "name": "related_decisions", + "span": { + "line": 2, + "col": 48 + } + } + ] + }, + "description": "SysML v2 systems-modeling requirement — traceable spec record, linked to the decisions it constrains" + } + }, + { + "type_name": "Decision", + "spec": { + "semantic_type": "decision", + "z_layer": "SystemsModel", + "rhai_dsl": { + "source": "let decision = record_decision(subject, rationale, decided_by);\nlink_requirements(decision.related_requirements);", + "symbols": [ + { + "kind": "Keyword", + "name": "let", + "span": { + "line": 1, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "decision", + "span": { + "line": 1, + "col": 5 + } + }, + { + "kind": "FunctionCall", + "name": "record_decision", + "span": { + "line": 1, + "col": 16 + } + }, + { + "kind": "Variable", + "name": "subject", + "span": { + "line": 1, + "col": 32 + } + }, + { + "kind": "Variable", + "name": "rationale", + "span": { + "line": 1, + "col": 41 + } + }, + { + "kind": "Variable", + "name": "decided_by", + "span": { + "line": 1, + "col": 52 + } + }, + { + "kind": "FunctionCall", + "name": "link_requirements", + "span": { + "line": 2, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "decision", + "span": { + "line": 2, + "col": 19 + } + }, + { + "kind": "Variable", + "name": "related_requirements", + "span": { + "line": 2, + "col": 28 + } + } + ] + }, + "description": "Version-controlled decision record — rationale + decider, linked back to the requirements it satisfies" + } + }, + { + "type_name": "Cost", + "spec": { + "semantic_type": "cost", + "z_layer": "SystemsModel", + "rhai_dsl": { + "source": "let cost = record_cost(subject, amount, currency);\nif cost.related_decision.is_some() { attribute_to_decision(cost) }", + "symbols": [ + { + "kind": "Keyword", + "name": "let", + "span": { + "line": 1, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "cost", + "span": { + "line": 1, + "col": 5 + } + }, + { + "kind": "FunctionCall", + "name": "record_cost", + "span": { + "line": 1, + "col": 12 + } + }, + { + "kind": "Variable", + "name": "subject", + "span": { + "line": 1, + "col": 24 + } + }, + { + "kind": "Variable", + "name": "amount", + "span": { + "line": 1, + "col": 33 + } + }, + { + "kind": "Variable", + "name": "currency", + "span": { + "line": 1, + "col": 41 + } + }, + { + "kind": "Keyword", + "name": "if", + "span": { + "line": 2, + "col": 1 + } + }, + { + "kind": "Variable", + "name": "cost", + "span": { + "line": 2, + "col": 4 + } + }, + { + "kind": "Variable", + "name": "related_decision", + "span": { + "line": 2, + "col": 9 + } + }, + { + "kind": "FunctionCall", + "name": "is_some", + "span": { + "line": 2, + "col": 26 + } + }, + { + "kind": "FunctionCall", + "name": "attribute_to_decision", + "span": { + "line": 2, + "col": 38 + } + }, + { + "kind": "Variable", + "name": "cost", + "span": { + "line": 2, + "col": 60 + } + } + ] + }, + "description": "Version-controlled cost record — monetary amount attributed to a decision for systems-modeling cost traceability" + } } ] } \ No newline at end of file diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 500f158..5b4960a 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -15,6 +15,7 @@ name = "xtask_mcpb" path = "src/lib.rs" [dependencies] +arc-kit-au = { path = "../crates/arc-kit-au" } clap = { version = "4", features = ["derive"] } ledger-core = { path = "../crates/ledger-core" } hex = "0.4" diff --git a/xtask/src/viz_manifest.rs b/xtask/src/viz_manifest.rs index 43e2751..aa0750c 100644 --- a/xtask/src/viz_manifest.rs +++ b/xtask/src/viz_manifest.rs @@ -1,12 +1,13 @@ //! Export the VisualizationSpec JSON manifest for the docs UI. //! -//! Collects `HasVisualization::viz_spec()` from all 28 domain types (every +//! Collects `HasVisualization::viz_spec()` from all 31 domain types (every //! `impl HasVisualization` in `ledger_core::iso_objects`, with the generic //! `StageResult` represented once via `StageResult<()>`) and writes a //! `VizManifest` JSON file to the specified output path. use std::path::Path; +use arc_kit_au::node::{Cost, Decision, Requirement}; use ledger_core::{ au_rd::{AuRdActivity, AuRdOffset}, constraints::{ @@ -74,6 +75,9 @@ pub fn export_viz_manifest(output: &Path) -> Result<(), Box