From 7fb20b5e1faea01d0665721c98e99bd337a3170d Mon Sep 17 00:00:00 2001 From: Brian H Date: Sun, 20 Sep 2026 06:50:42 +0000 Subject: [PATCH 1/3] feat: add validated ReqIF intake contract --- .b00t/tasks.json | 8 + Cargo.lock | 1 + containers/kr0ki-mcp/bridge.py | 6 +- crates/kr0ki-core/Cargo.toml | 4 + crates/kr0ki-core/src/lib.rs | 1 + crates/kr0ki-core/src/mcp_tool.rs | 28 +- crates/kr0ki-core/src/reqif_import.rs | 450 ++++++++++++++++++ .../fixtures/reqif/minimal-no-namespace.reqif | 16 + .../tests/fixtures/reqif/minimal.reqif | 16 + crates/kr0ki-server/src/app.rs | 39 +- crates/kr0ki-server/tests/http.rs | 64 ++- docs/TODO.md | 30 +- 12 files changed, 649 insertions(+), 14 deletions(-) create mode 100644 crates/kr0ki-core/src/reqif_import.rs create mode 100644 crates/kr0ki-core/tests/fixtures/reqif/minimal-no-namespace.reqif create mode 100644 crates/kr0ki-core/tests/fixtures/reqif/minimal.reqif diff --git a/.b00t/tasks.json b/.b00t/tasks.json index 0a7b6d4..9c2bca9 100644 --- a/.b00t/tasks.json +++ b/.b00t/tasks.json @@ -433,6 +433,14 @@ "tags": [], "created_at": "2026-09-19T12:56:26Z", "updated_at": "2026-09-19T13:06:55Z" + }, + { + "id": 53, + "title": "kr0ki: harden ReqIF and ReqIFz intake then expose a validated import contract", + "status": "pending", + "priority": 3, + "tags": [], + "created_at": "2026-09-20T06:18:55Z" } ], "version": "1" diff --git a/Cargo.lock b/Cargo.lock index 89487cc..619d228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,7 @@ dependencies = [ "usvg", "walkdir", "wiremock", + "zip", ] [[package]] diff --git a/containers/kr0ki-mcp/bridge.py b/containers/kr0ki-mcp/bridge.py index 27afb20..bbc645a 100644 --- a/containers/kr0ki-mcp/bridge.py +++ b/containers/kr0ki-mcp/bridge.py @@ -53,8 +53,10 @@ def call_tool(arguments): if tool is None: return tool_error(f"unknown tool: {name}") - for key in ("source", "manifest"): - value = params.get(key) + for argument in tool["httpBinding"]["args"]: + if argument["placement"] != "body": + continue + value = params.get(argument["name"]) if isinstance(value, str) and len(value.encode("utf-8")) > MAX_INPUT_BYTES: return tool_error("input exceeds the 1 MiB MCP bridge limit") diff --git a/crates/kr0ki-core/Cargo.toml b/crates/kr0ki-core/Cargo.toml index 8410d0f..976e478 100644 --- a/crates/kr0ki-core/Cargo.toml +++ b/crates/kr0ki-core/Cargo.toml @@ -47,6 +47,10 @@ toml = "0.8" # Rust rather than calling `reqif-opa-mcp` as a sidecar. Apache-2.0, a direct # Rust port of the same StrictDoc `reqif` reference implementation. reqrs = "0.2.2" +# ReqIFz is a ZIP container. `reqrs` exposes a path-only ReqIfzBundle API; +# kr0ki reads archives in-memory so the import boundary can enforce its own +# compressed and expanded byte limits before retaining any attachment metadata. +zip = { version = "2.4.2", default-features = false, features = ["deflate"] } [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros", "fs", "net", "rt-multi-thread"] } diff --git a/crates/kr0ki-core/src/lib.rs b/crates/kr0ki-core/src/lib.rs index 9ac9c33..26f3ee9 100644 --- a/crates/kr0ki-core/src/lib.rs +++ b/crates/kr0ki-core/src/lib.rs @@ -37,6 +37,7 @@ pub mod mcp_tool; pub mod probe; pub mod render; pub mod reqif_adapter; +pub mod reqif_import; /// Canonical home is `ufo_types::mbse::requirements` (kr0ki M1, /// 2026-09-19) — re-exported here so existing `kr0ki_core::requirements::*` /// call sites keep working without a local copy of the types. diff --git a/crates/kr0ki-core/src/mcp_tool.rs b/crates/kr0ki-core/src/mcp_tool.rs index 60f458f..69faafc 100644 --- a/crates/kr0ki-core/src/mcp_tool.rs +++ b/crates/kr0ki-core/src/mcp_tool.rs @@ -17,6 +17,7 @@ pub enum McpTool { RenderKubeDiagram, RenderK8sTopology, RenderSysmlV2Snapshot, + ImportReqIf, ListModelProjects, ListModelCommits, GetModelSnapshot, @@ -33,6 +34,7 @@ impl McpTool { Self::RenderKubeDiagram, Self::RenderK8sTopology, Self::RenderSysmlV2Snapshot, + Self::ImportReqIf, Self::ListModelProjects, Self::ListModelCommits, Self::GetModelSnapshot, @@ -49,6 +51,7 @@ impl McpTool { Self::RenderKubeDiagram => "render_kubernetes_manifest", Self::RenderK8sTopology => "render_kubernetes_topology", Self::RenderSysmlV2Snapshot => "render_sysmlv2_snapshot", + Self::ImportReqIf => "import_reqif", Self::ListModelProjects => "list_model_projects", Self::ListModelCommits => "list_model_commits", Self::GetModelSnapshot => "get_model_snapshot", @@ -76,6 +79,9 @@ impl McpTool { Self::RenderSysmlV2Snapshot => { "Render a validated SysML v2 project commit from the configured model server." } + Self::ImportReqIf => { + "Validate and normalize a UTF-8 ReqIF document. For binary ReqIFz archives, use POST /requirements/import." + } Self::ListModelProjects => "List SysML v2 projects on the configured model server.", Self::ListModelCommits => "List commits (immutable model snapshots) for a project.", Self::GetModelSnapshot => { @@ -129,6 +135,16 @@ impl McpTool { "output": {"type": "string", "enum": ["svg", "png"], "default": "svg"} } }), + Self::ImportReqIf => serde_json::json!({ + "type": "object", + "required": ["artifact"], + "properties": { + "artifact": { + "type": "string", + "description": "UTF-8 ReqIF XML. Use the HTTP upload route for binary ReqIFz." + } + } + }), Self::ListModelProjects => serde_json::json!({"type": "object", "properties": {}}), Self::ListModelCommits => serde_json::json!({ "type": "object", "required": ["project_id"], @@ -220,6 +236,14 @@ impl McpTool { ArgBinding { name: "output", placement: ArgPlacement::Query }, ], }, + Self::ImportReqIf => HttpBinding { + method: HttpMethod::Post, + path_template: "/requirements/import", + args: &[ArgBinding { + name: "artifact", + placement: ArgPlacement::Body, + }], + }, Self::ListModelProjects => HttpBinding { method: HttpMethod::Get, path_template: "/model/projects", args: &[] }, Self::ListModelCommits => HttpBinding { method: HttpMethod::Get, path_template: "/model/projects/{project_id}/commits", @@ -322,13 +346,13 @@ mod tests { use super::*; #[test] - fn all_twelve_tools_have_unique_names() { + fn all_thirteen_tools_have_unique_names() { let mut names: Vec<&str> = McpTool::ALL.iter().map(|t| t.name()).collect(); let before = names.len(); names.sort_unstable(); names.dedup(); assert_eq!(names.len(), before, "duplicate McpTool name in ALL"); - assert_eq!(McpTool::ALL.len(), 12); + assert_eq!(McpTool::ALL.len(), 13); } #[test] diff --git a/crates/kr0ki-core/src/reqif_import.rs b/crates/kr0ki-core/src/reqif_import.rs new file mode 100644 index 0000000..b0367dd --- /dev/null +++ b/crates/kr0ki-core/src/reqif_import.rs @@ -0,0 +1,450 @@ +//! Bounded, in-memory ReqIF / ReqIFz intake for the M2 interchange boundary. +//! +//! This module deliberately accepts bytes rather than paths or URLs. The +//! caller owns acquisition policy (browser upload, an allow-listed local path, +//! or a separately hardened HTTPS fetch); intake owns format detection, size +//! limits, hashing, parsing, attachment inventory, and normalization. That +//! division keeps an HTTP route from becoming an unrestricted server-side +//! file or network reader. + +use std::io::{Cursor, Read}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use zip::ZipArchive; + +use crate::{ + reqif_adapter::{bundle_to_requirement_graph, ReqIfAdapterConfig, ReqIfAdapterError}, + requirements::RequirementGraph, +}; + +/// Default cap for both the uploaded artifact and an expanded ReqIFz archive. +/// +/// The limits are configurable so a deployment can set a smaller policy; the +/// same cap on the expanded archive prevents a small compressed ZIP from +/// bypassing the HTTP upload limit. +pub const DEFAULT_MAX_REQIF_IMPORT_BYTES: usize = 32 * 1024 * 1024; + +/// A byte cap alone does not bound CPU spent traversing a ZIP made of many +/// empty entries, so archives also have a conservative entry-count limit. +pub const DEFAULT_MAX_REQIFZ_ENTRIES: usize = 4_096; + +/// `reqrs` is intentionally pinned in Cargo.toml. Keep this evidence string +/// in sync with that dependency when upgrading the parser. +pub const REQRS_PARSER_VERSION: &str = "0.2.2"; + +/// The shared requirements contract is supplied by `ufo-types` v0.15.0. +/// This is evidence metadata, not a second schema definition. +pub const REQUIREMENT_GRAPH_CONTRACT_VERSION: &str = "ufo-types/0.15.0:mbse::requirements"; + +/// Serialized b00t-type interface carried by every ReqIF import result. +/// +/// This is a metadata dimension of the artifact contract: callers can select +/// a compatible deserializer without inferring its shape from a file suffix or +/// the transport that supplied the source bytes. +pub const B00T_REQIF_INTAKE_INTERFACE: &str = "b00t.type/reqif-import/v1"; + +/// Acquisition-neutral policy and provenance supplied by the caller. +#[derive(Debug, Clone)] +pub struct ReqIfImportConfig { + /// Opaque provenance for the source. If absent, import uses the artifact + /// digest as an immutable `urn:sha256:` locator. + pub source_uri: Option, + /// Immutable source revision when an upstream system has one. Uploads + /// default to the artifact digest. + pub revision: Option, + pub max_artifact_bytes: usize, + pub max_expanded_bytes: usize, + pub max_archive_entries: usize, +} + +impl Default for ReqIfImportConfig { + fn default() -> Self { + Self { + source_uri: None, + revision: None, + max_artifact_bytes: DEFAULT_MAX_REQIF_IMPORT_BYTES, + max_expanded_bytes: DEFAULT_MAX_REQIF_IMPORT_BYTES, + max_archive_entries: DEFAULT_MAX_REQIFZ_ENTRIES, + } + } +} + +/// A parsed document from an imported artifact. A plain `.reqif` produces +/// one document with no `entry_name`; `.reqifz` may contain several. +#[derive(Debug, Clone, Serialize)] +pub struct ImportedReqIfDocument { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_name: Option, + /// SHA-256 of this document's normalized semantic graph serialized with + /// stable struct field order and BTreeMap attribute order. + pub normalized_graph_sha256: String, + pub graph: RequirementGraph, +} + +/// Versions that made the parser and normalized-graph validation decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReqIfValidationIdentity { + pub parser: &'static str, + pub parser_version: &'static str, + pub normalized_graph_contract: &'static str, +} + +/// Inventory only — kr0ki does not make attachments durable. A Flexo or +/// other requirements store can retain bytes after applying its own policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReqIfAttachment { + pub entry_name: String, + pub byte_len: usize, + pub sha256: String, +} + +/// Result of one validated input artifact. Its digest identifies exactly the +/// bytes that produced every returned baseline and attachment inventory row. +#[derive(Debug, Clone, Serialize)] +pub struct ReqIfImportResult { + /// `b00t.type/reqif-import/v1`: stable serialized interface identity for + /// this result, distinct from the ReqIF source encoding and graph schema. + pub b00t_type: &'static str, + pub artifact_sha256: String, + pub artifact_byte_len: usize, + pub validation: ReqIfValidationIdentity, + pub documents: Vec, + pub attachments: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReqIfImportError { + #[error("ReqIF artifact is {actual} bytes; maximum is {maximum} bytes")] + ArtifactTooLarge { actual: usize, maximum: usize }, + #[error("ReqIFz expands beyond the {maximum}-byte policy limit")] + ExpandedTooLarge { maximum: usize }, + #[error("ReqIFz contains {actual} entries; maximum is {maximum}")] + TooManyArchiveEntries { actual: usize, maximum: usize }, + #[error("ReqIFz contains no .reqif or .xml document")] + NoDocuments, + #[error("invalid ReqIFz archive: {0}")] + Archive(String), + #[error(transparent)] + Parse(#[from] reqrs::ReqIfError), + #[error(transparent)] + Adapter(#[from] ReqIfAdapterError), + #[error("could not serialize the normalized requirements graph: {0}")] + CanonicalGraph(#[from] serde_json::Error), +} + +/// Import a plain ReqIF XML document or a ReqIFz ZIP artifact from bytes. +/// +/// ZIP detection intentionally uses the full family of ZIP signatures. The +/// archive is never unpacked to disk, and every entry contributes to the +/// expanded-size budget, including attachments that this stateless service +/// only inventories. +pub fn import_reqif_artifact( + bytes: &[u8], + config: &ReqIfImportConfig, +) -> Result { + if bytes.len() > config.max_artifact_bytes { + return Err(ReqIfImportError::ArtifactTooLarge { + actual: bytes.len(), + maximum: config.max_artifact_bytes, + }); + } + + let artifact_sha256 = sha256_hex(bytes); + if is_zip(bytes) { + import_reqifz(bytes, config, artifact_sha256) + } else { + let document = parse_document(bytes, None, config, &artifact_sha256)?; + Ok(ReqIfImportResult { + b00t_type: B00T_REQIF_INTAKE_INTERFACE, + artifact_sha256, + artifact_byte_len: bytes.len(), + validation: validation_identity(), + documents: vec![document], + attachments: Vec::new(), + }) + } +} + +fn import_reqifz( + bytes: &[u8], + config: &ReqIfImportConfig, + artifact_sha256: String, +) -> Result { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| ReqIfImportError::Archive(error.to_string()))?; + if archive.len() > config.max_archive_entries { + return Err(ReqIfImportError::TooManyArchiveEntries { + actual: archive.len(), + maximum: config.max_archive_entries, + }); + } + let mut expanded_bytes = 0usize; + let mut documents = Vec::new(); + let mut attachments = Vec::new(); + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| ReqIfImportError::Archive(error.to_string()))?; + if entry.is_dir() { + continue; + } + + let entry_name = entry.name().to_string(); + let declared_size = + usize::try_from(entry.size()).map_err(|_| ReqIfImportError::ExpandedTooLarge { + maximum: config.max_expanded_bytes, + })?; + reserve_expanded( + &mut expanded_bytes, + declared_size, + config.max_expanded_bytes, + )?; + + let mut entry_bytes = Vec::with_capacity(declared_size); + entry + .read_to_end(&mut entry_bytes) + .map_err(|error| ReqIfImportError::Archive(error.to_string()))?; + // Do not trust ZIP metadata alone: account for actual bytes too. + if entry_bytes.len() > declared_size { + reserve_expanded( + &mut expanded_bytes, + entry_bytes.len() - declared_size, + config.max_expanded_bytes, + )?; + } + + if is_reqif_document(&entry_name) { + let document = parse_document( + &entry_bytes, + Some(entry_name.as_str()), + config, + &artifact_sha256, + )?; + documents.push(document); + } else { + attachments.push(ReqIfAttachment { + entry_name, + byte_len: entry_bytes.len(), + sha256: sha256_hex(&entry_bytes), + }); + } + } + + if documents.is_empty() { + return Err(ReqIfImportError::NoDocuments); + } + + Ok(ReqIfImportResult { + b00t_type: B00T_REQIF_INTAKE_INTERFACE, + artifact_sha256, + artifact_byte_len: bytes.len(), + validation: validation_identity(), + documents, + attachments, + }) +} + +fn reserve_expanded( + used: &mut usize, + additional: usize, + maximum: usize, +) -> Result<(), ReqIfImportError> { + *used = used + .checked_add(additional) + .filter(|total| *total <= maximum) + .ok_or(ReqIfImportError::ExpandedTooLarge { maximum })?; + Ok(()) +} + +fn parse_document( + bytes: &[u8], + entry_name: Option<&str>, + config: &ReqIfImportConfig, + artifact_sha256: &str, +) -> Result { + let source_uri = config + .source_uri + .clone() + .unwrap_or_else(|| format!("urn:sha256:{artifact_sha256}")); + let source_uri = entry_name + .map(|name| format!("{source_uri}#{name}")) + .unwrap_or(source_uri); + let revision = config + .revision + .clone() + .unwrap_or_else(|| format!("sha256:{artifact_sha256}")); + let bundle = reqrs::ReqIfParser::parse_bytes(bytes)?; + let graph = bundle_to_requirement_graph( + &bundle, + &ReqIfAdapterConfig { + source_uri, + revision, + import_artifact_sha256: Some(artifact_sha256.to_string()), + }, + )?; + let normalized_graph_sha256 = sha256_hex(&serde_json::to_vec(&graph)?); + Ok(ImportedReqIfDocument { + entry_name: entry_name.map(str::to_owned), + normalized_graph_sha256, + graph, + }) +} + +fn validation_identity() -> ReqIfValidationIdentity { + ReqIfValidationIdentity { + parser: "reqrs", + parser_version: REQRS_PARSER_VERSION, + normalized_graph_contract: REQUIREMENT_GRAPH_CONTRACT_VERSION, + } +} + +fn is_zip(bytes: &[u8]) -> bool { + matches!(bytes, [b'P', b'K', 3 | 5 | 7, 4 | 6 | 8, ..]) +} + +fn is_reqif_document(name: &str) -> bool { + name.rsplit_once('.') + .map(|(_, extension)| matches!(extension.to_ascii_lowercase().as_str(), "reqif" | "xml")) + .unwrap_or(false) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use zip::write::{SimpleFileOptions, ZipWriter}; + + use super::*; + + const MINIMAL_REQIF: &[u8] = include_bytes!("../tests/fixtures/reqif/minimal.reqif"); + const NO_NAMESPACE_REQIF: &[u8] = + include_bytes!("../tests/fixtures/reqif/minimal-no-namespace.reqif"); + + #[test] + fn imports_namespace_less_reqif_with_digest_provenance() { + let imported = import_reqif_artifact(NO_NAMESPACE_REQIF, &ReqIfImportConfig::default()) + .expect("reqrs supports namespace-less ReqIF"); + let repeated = import_reqif_artifact(NO_NAMESPACE_REQIF, &ReqIfImportConfig::default()) + .expect("the same artifact remains reproducible"); + + assert_eq!(imported.documents.len(), 1); + assert_eq!(imported.documents[0].graph.baseline.id, "BL-NO-NAMESPACE"); + assert_eq!( + imported.documents[0] + .graph + .baseline + .import_artifact_sha256 + .as_deref(), + Some(imported.artifact_sha256.as_str()) + ); + assert_eq!( + imported.documents[0].graph.requirements.len(), + 0, + "minimal interchange fixture is a valid empty baseline" + ); + assert_eq!( + imported.documents[0].normalized_graph_sha256, + repeated.documents[0].normalized_graph_sha256, + "canonical graph bytes have a stable digest" + ); + assert_eq!(imported.validation.parser, "reqrs"); + assert_eq!(imported.b00t_type, B00T_REQIF_INTAKE_INTERFACE); + assert_eq!(imported.validation.parser_version, REQRS_PARSER_VERSION); + assert_eq!( + imported.validation.normalized_graph_contract, + REQUIREMENT_GRAPH_CONTRACT_VERSION + ); + } + + #[test] + fn rejects_malformed_xml_with_a_typed_parse_error() { + let error = import_reqif_artifact(b"", &ReqIfImportConfig::default()) + .unwrap_err(); + assert!(matches!(error, ReqIfImportError::Parse(_))); + } + + #[test] + fn rejects_artifacts_over_the_configured_upload_limit() { + let config = ReqIfImportConfig { + max_artifact_bytes: MINIMAL_REQIF.len() - 1, + ..ReqIfImportConfig::default() + }; + let error = import_reqif_artifact(MINIMAL_REQIF, &config).unwrap_err(); + assert!(matches!(error, ReqIfImportError::ArtifactTooLarge { .. })); + } + + #[test] + fn imports_reqifz_and_inventories_attachments() { + let mut bytes = Vec::new(); + { + let mut archive = ZipWriter::new(Cursor::new(&mut bytes)); + let options = SimpleFileOptions::default(); + archive.start_file("baseline.reqif", options).unwrap(); + archive.write_all(MINIMAL_REQIF).unwrap(); + archive.start_file("evidence.txt", options).unwrap(); + archive.write_all(b"review evidence").unwrap(); + archive.finish().unwrap(); + } + + let imported = import_reqif_artifact(&bytes, &ReqIfImportConfig::default()).unwrap(); + assert_eq!(imported.documents.len(), 1); + assert_eq!( + imported.documents[0].entry_name.as_deref(), + Some("baseline.reqif") + ); + assert_eq!(imported.attachments.len(), 1); + assert_eq!(imported.attachments[0].entry_name, "evidence.txt"); + assert_eq!(imported.attachments[0].byte_len, b"review evidence".len()); + } + + #[test] + fn rejects_reqifz_that_exceeds_the_expanded_limit() { + let mut bytes = Vec::new(); + { + let mut archive = ZipWriter::new(Cursor::new(&mut bytes)); + let options = SimpleFileOptions::default(); + archive.start_file("baseline.reqif", options).unwrap(); + archive.write_all(MINIMAL_REQIF).unwrap(); + archive.start_file("large-attachment.bin", options).unwrap(); + archive.write_all(&[0; 128]).unwrap(); + archive.finish().unwrap(); + } + let config = ReqIfImportConfig { + max_expanded_bytes: MINIMAL_REQIF.len(), + ..ReqIfImportConfig::default() + }; + + let error = import_reqif_artifact(&bytes, &config).unwrap_err(); + assert!(matches!(error, ReqIfImportError::ExpandedTooLarge { .. })); + } + + #[test] + fn rejects_reqifz_with_too_many_entries_before_reading_them() { + let mut bytes = Vec::new(); + { + let mut archive = ZipWriter::new(Cursor::new(&mut bytes)); + let options = SimpleFileOptions::default(); + archive.start_file("one.txt", options).unwrap(); + archive.write_all(b"1").unwrap(); + archive.start_file("two.txt", options).unwrap(); + archive.write_all(b"2").unwrap(); + archive.finish().unwrap(); + } + let config = ReqIfImportConfig { + max_archive_entries: 1, + ..ReqIfImportConfig::default() + }; + + let error = import_reqif_artifact(&bytes, &config).unwrap_err(); + assert!(matches!( + error, + ReqIfImportError::TooManyArchiveEntries { .. } + )); + } +} diff --git a/crates/kr0ki-core/tests/fixtures/reqif/minimal-no-namespace.reqif b/crates/kr0ki-core/tests/fixtures/reqif/minimal-no-namespace.reqif new file mode 100644 index 0000000..15ebaa0 --- /dev/null +++ b/crates/kr0ki-core/tests/fixtures/reqif/minimal-no-namespace.reqif @@ -0,0 +1,16 @@ + + + + Namespace optional fixture + + + + + + + + + + + + diff --git a/crates/kr0ki-core/tests/fixtures/reqif/minimal.reqif b/crates/kr0ki-core/tests/fixtures/reqif/minimal.reqif new file mode 100644 index 0000000..e56ff85 --- /dev/null +++ b/crates/kr0ki-core/tests/fixtures/reqif/minimal.reqif @@ -0,0 +1,16 @@ + + + + Minimal baseline + + + + + + + + + + + + diff --git a/crates/kr0ki-server/src/app.rs b/crates/kr0ki-server/src/app.rs index 793d2c3..6e5a3df 100644 --- a/crates/kr0ki-server/src/app.rs +++ b/crates/kr0ki-server/src/app.rs @@ -6,7 +6,7 @@ use std::{path::Component, path::PathBuf}; use axum::{ body::Bytes, - extract::{Path, State}, + extract::{DefaultBodyLimit, Path, State}, http::{header, StatusCode}, response::{IntoResponse, Redirect, Response}, routing::{get, post}, @@ -79,6 +79,12 @@ pub fn router(state: AppState, auth_token: Option) -> Router { .route("/playbook", get(playbook_index)) .route("/playbook/", get(playbook_index)) .route("/playbook/*path", get(playbook_asset)) + .route( + "/requirements/import", + post(import_requirements).layer(DefaultBodyLimit::max( + kr0ki_core::reqif_import::DEFAULT_MAX_REQIF_IMPORT_BYTES, + )), + ) .route("/requirements/views", post(requirements_view)) .route("/render/:format", post(render)) .route("/render/requirements-view", post(render_requirements_view)) @@ -227,6 +233,37 @@ struct RequirementsViewInput { request: kr0ki_core::requirements::ViewRequest, } +/// `POST /requirements/import` — accept a raw ReqIF XML document or ReqIFz +/// archive, validate it under the bounded M2 intake policy, and return the +/// resulting normalized graphs. This service never persists the source or +/// attachments: a requirements store (for example Flexo) owns retention. +/// +/// The route accepts bytes rather than a `file://` or arbitrary URL so a +/// deployment cannot accidentally turn kr0ki into a server-side file/network +/// reader. A future approved HTTPS fetcher and an allow-listed CLI/MCP local +/// loader can call the same core import boundary after acquiring bytes. +async fn import_requirements(body: Bytes) -> Response { + match kr0ki_core::reqif_import::import_reqif_artifact( + &body, + &kr0ki_core::reqif_import::ReqIfImportConfig::default(), + ) { + Ok(imported) => Json(imported).into_response(), + Err( + kr0ki_core::reqif_import::ReqIfImportError::ArtifactTooLarge { .. } + | kr0ki_core::reqif_import::ReqIfImportError::ExpandedTooLarge { .. }, + ) => error_json( + StatusCode::PAYLOAD_TOO_LARGE, + "reqif_import_too_large", + "ReqIF import exceeds this deployment's size policy", + ), + Err(error) => error_json( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_reqif_import", + &error.to_string(), + ), + } +} + /// `POST /requirements/views` — return an induced typed graph, not diagram /// source. This is the stable view contract for requirements clients. async fn requirements_view(Json(input): Json) -> Response { diff --git a/crates/kr0ki-server/tests/http.rs b/crates/kr0ki-server/tests/http.rs index a604f22..9b8474b 100644 --- a/crates/kr0ki-server/tests/http.rs +++ b/crates/kr0ki-server/tests/http.rs @@ -113,7 +113,7 @@ async fn formats_lists_supported_slugs_only() { } #[tokio::test] -async fn mcp_tools_lists_all_twelve_tools_with_bindings() { +async fn mcp_tools_lists_all_thirteen_tools_with_bindings() { let app = test_app(test_state("mcp-tools")); let resp = app .oneshot(Request::get("/mcp/tools").body(Body::empty()).unwrap()) @@ -122,13 +122,14 @@ async fn mcp_tools_lists_all_twelve_tools_with_bindings() { let (status, body) = body_string(resp).await; assert_eq!(status, StatusCode::OK); let tools: Vec = serde_json::from_str(&body).unwrap(); - assert_eq!(tools.len(), 12); + assert_eq!(tools.len(), 13); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"render_diagram")); assert!(names.contains(&"list_formats")); assert!(names.contains(&"render_kubernetes_manifest")); assert!(names.contains(&"render_kubernetes_topology")); assert!(names.contains(&"render_sysmlv2_snapshot")); + assert!(names.contains(&"import_reqif")); assert!(names.contains(&"query_model_graph")); let render = tools @@ -143,6 +144,65 @@ async fn mcp_tools_lists_all_twelve_tools_with_bindings() { .contains(&serde_json::json!("format"))); } +const MINIMAL_REQIF: &str = include_str!("../../kr0ki-core/tests/fixtures/reqif/minimal.reqif"); + +#[tokio::test] +async fn requirements_import_normalizes_raw_reqif_with_digest_provenance() { + let response = test_app(test_state("requirements-import")) + .oneshot( + Request::post("/requirements/import") + .header("content-type", "application/reqif+xml") + .body(Body::from(MINIMAL_REQIF)) + .unwrap(), + ) + .await + .unwrap(); + let (status, body) = body_string(response).await; + + assert_eq!(status, StatusCode::OK, "body: {body}"); + let imported: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(imported["documents"].as_array().unwrap().len(), 1); + assert_eq!( + imported["documents"][0]["graph"]["baseline"]["id"], + "BL-MINIMAL" + ); + assert_eq!( + imported["documents"][0]["graph"]["baseline"]["import_artifact_sha256"], + imported["artifact_sha256"] + ); +} + +#[tokio::test] +async fn requirements_import_rejects_malformed_reqif_without_calling_a_renderer() { + let response = test_app(test_state("requirements-import-malformed")) + .oneshot( + Request::post("/requirements/import") + .body(Body::from("")) + .unwrap(), + ) + .await + .unwrap(); + let (status, body) = body_string(response).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert!(body.contains("invalid_reqif_import"), "body: {body}"); +} + +#[tokio::test] +async fn requirements_import_rejects_an_oversized_upload_at_the_http_boundary() { + let too_large = vec![0_u8; kr0ki_core::reqif_import::DEFAULT_MAX_REQIF_IMPORT_BYTES + 1]; + let response = test_app(test_state("requirements-import-too-large")) + .oneshot( + Request::post("/requirements/import") + .body(Body::from(too_large)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + fn requirement_view_payload(kind: &str, confirmed_behaviour: Option<&str>) -> String { serde_json::json!({ "graph": { diff --git a/docs/TODO.md b/docs/TODO.md index 05f38b7..65a6f7d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -56,13 +56,29 @@ starting this stream. kr0ki's stateless view slice is on text/subtype recovered via the same `name`/`title`/`key`/`text`/ `description` attribute-name convention as `normalization.py`). 7 tests, parse-through-map, `cargo clippy -D warnings` clean. - - [ ] Verify `reqrs`'s behavior on the `reqif` 0.0.48 no-namespace bug found - above before depending on it for namespace-optional input. - - [ ] Cover malformed input, ReqIFz attachments, large-file progress, and - round trips against upstream fixtures. - - [ ] Wire the adapter into an HTTP/MCP import route once M3 needs it — - `bundle_to_requirement_graph` is a pure function today, not yet called - from anywhere outside its own tests. + - [x] `reqrs` accepts a namespace-less `` document correctly — the + no-namespace defect found in `reqif` 0.0.48 is not inherited. A fixture + test proves it normalizes a valid empty baseline with digest provenance. + - [x] **Bounded raw import contract** — `kr0ki_core::reqif_import` accepts + plain ReqIF XML or in-memory ReqIFz bytes; it hashes the complete input, + enforces both compressed and expanded limits (32 MiB by default), maps + every embedded ReqIF document, and inventories attachment digests without + retaining their bytes. `POST /requirements/import` is the matching raw + upload route; MCP `import_reqif` accepts UTF-8 XML through the generic + manifest bridge (the bridge's independent 1 MiB text cap remains in + force). Tests cover malformed XML, no-namespace input, ReqIFz attachments, + core and HTTP size limits, and the endpoint's no-renderer failure path. + - [ ] Stream progress for large uploads/fetches, add an approved upstream + fixture corpus, and prove deterministic ReqIF/ReqIFz export round trips. + The current result is deliberately stateless inventory, not attachment + retention or a durable baseline store. + - [ ] **Validated source acquisition** — add callers over the bounded-byte + import seam, not new parser paths: browser/API uploads; a CLI/MCP local + loader confined to an allow-listed root; and an HTTPS-only fetcher with + DNS/IP SSRF checks, redirect policy, time/size limits, and URI/ETag/ + Last-Modified provenance. Never expose arbitrary server-side `file://` + reads. Keep source/validator/graph/view-recipe digests together so a + derived SVG remains reproducible. - [ ] **`reqif-opa-mcp` refactor** — retain its artifact → document graph → candidate → OPA → ReqIF pipeline; replace its private requirement DTO/relation/provenance and validation types with the upstream `ufo-types` contract. From bb2c6a807a4f69d397b2b9bc6b9ad464ba46f235 Mon Sep 17 00:00:00 2001 From: Brian H Date: Sun, 20 Sep 2026 08:43:00 +0000 Subject: [PATCH 2/3] refactor(reqif): move ReqIF adapter into ufo_types::reqif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqif_adapter.rs (ReqIfBundle -> RequirementGraph mapping) and the reqrs parse call move into ufo-types' new `reqif` feature module (sibling commit a1f6a89, PromptExecution/ufo-types), per docs/DESIGN-NOTE-versioned-dialects-and-b00t-loader.md §3: the orphan rule means kr0ki-core can't impl a foreign trait (Upgrade) on a foreign type (RequirementGraph), so the bridge lives where the type does. reqif_import.rs now calls ufo_types::reqif::parse_and_lower instead of reqrs directly; its own ReqIfImportError::Parse and ::Adapter variants collapse into one (ufo_types::reqif's own ReqIfAdapterError already covers both). No change to the shipped HTTP/MCP contract shape (POST /requirements/import, import_reqif tool) -- verified by the existing kr0ki-server requirements_import_* tests, unmodified and passing. Depends on the sibling ufo-types commit above landing (tag bump) -- not independently buildable without it. Cargo.lock and the root Cargo.toml's dev-only ufo-types path [patch] are intentionally left uncommitted; the patch is local-only wiring to build against that commit before it's tagged, not a real dependency change. Co-Authored-By: Claude Sonnet 5 --- crates/kr0ki-core/Cargo.toml | 19 +- crates/kr0ki-core/src/lib.rs | 1 - crates/kr0ki-core/src/reqif_adapter.rs | 567 ------------------------- crates/kr0ki-core/src/reqif_import.rs | 15 +- 4 files changed, 15 insertions(+), 587 deletions(-) delete mode 100644 crates/kr0ki-core/src/reqif_adapter.rs diff --git a/crates/kr0ki-core/Cargo.toml b/crates/kr0ki-core/Cargo.toml index 976e478..0868fcb 100644 --- a/crates/kr0ki-core/Cargo.toml +++ b/crates/kr0ki-core/Cargo.toml @@ -27,7 +27,7 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" } # CHANGELOG guidance ("pin an exact version or a git tag"). `ontology` / # `sysml_model` / `view` are default (not feature-gated) — no extra features # needed here. -ufo-types = { git = "https://github.com/PromptExecution/ufo-types", tag = "v0.15.0" } +ufo-types = { git = "https://github.com/PromptExecution/ufo-types", tag = "v0.15.0", features = ["reqif"] } # kr0ki#13 — box 5 (b00t-graph Turtle -> D2 route): the typed TypeRelationshipGraph / # CytoscapeGraph the D2 emitter targets. No tagged release exists yet (checked # 2026-09-16); pinned by git rev per this issue's own operator decision, to be @@ -42,14 +42,15 @@ oxrdf = "0.3" # source.md #5): reads workspace Cargo.toml files to tell a workspace-sibling # crate apart from a genuine external dependency. toml = "0.8" -# M2 (docs/TODO.md "ReqIF / ReqIFz adapter", decided 2026-09-20 -- see -# docs/HANDOFF-2026-09-19-reqif-flexo.md): parse ReqIF 1.2 XML directly in -# Rust rather than calling `reqif-opa-mcp` as a sidecar. Apache-2.0, a direct -# Rust port of the same StrictDoc `reqif` reference implementation. -reqrs = "0.2.2" -# ReqIFz is a ZIP container. `reqrs` exposes a path-only ReqIfzBundle API; -# kr0ki reads archives in-memory so the import boundary can enforce its own -# compressed and expanded byte limits before retaining any attachment metadata. +# M2 (docs/TODO.md "ReqIF / ReqIFz adapter"): ReqIF 1.2 XML parsing + the +# ReqIfBundle -> RequirementGraph adapter moved into `ufo_types::reqif` +# (2026-09-20, `docs/DESIGN-NOTE-versioned-dialects-and-b00t-loader.md` §3) -- +# enabled via the `reqif` feature above. No direct `reqrs` dependency here +# anymore; this crate only owns bounded-bytes acquisition (size/entry-count +# limits, hashing, the HTTP/MCP envelope), not parsing. +# ReqIFz is a ZIP container. kr0ki reads archives in-memory so the import +# boundary can enforce its own compressed and expanded byte limits before +# retaining any attachment metadata. zip = { version = "2.4.2", default-features = false, features = ["deflate"] } [dev-dependencies] diff --git a/crates/kr0ki-core/src/lib.rs b/crates/kr0ki-core/src/lib.rs index 26f3ee9..88440d8 100644 --- a/crates/kr0ki-core/src/lib.rs +++ b/crates/kr0ki-core/src/lib.rs @@ -36,7 +36,6 @@ pub mod k8s_recognizer; pub mod mcp_tool; pub mod probe; pub mod render; -pub mod reqif_adapter; pub mod reqif_import; /// Canonical home is `ufo_types::mbse::requirements` (kr0ki M1, /// 2026-09-19) — re-exported here so existing `kr0ki_core::requirements::*` diff --git a/crates/kr0ki-core/src/reqif_adapter.rs b/crates/kr0ki-core/src/reqif_adapter.rs deleted file mode 100644 index 48cf141..0000000 --- a/crates/kr0ki-core/src/reqif_adapter.rs +++ /dev/null @@ -1,567 +0,0 @@ -//! ReqIF -> [`crate::requirements::RequirementGraph`] adapter (kr0ki M2, -//! `docs/TODO.md` "ReqIF / ReqIFz adapter"). -//! -//! Parsing itself is [`reqrs`]'s job (decided 2026-09-20 — see -//! `docs/HANDOFF-2026-09-19-reqif-flexo.md`'s "Deliberate boundaries" and -//! [kr0ki#39](https://github.com/PromptExecution/kr0ki/issues/39)): a direct -//! Rust dependency, not a sidecar call to the vendored `reqif-opa-mcp` -//! submodule. This module's job is narrowed to one thing — lower a parsed -//! [`reqrs::model::ReqIfBundle`] onto [`crate::requirements::RequirementGraph`] -//! — mirroring the mapping already written once in Python for -//! `reqif-opa-mcp` (`reqif_mcp/reqif_parser.py` + -//! `reqif_mcp/normalization.py`, `PromptExecution/reqif-opa-mcp#25`), except -//! the target here is the shared semantic contract, not a bespoke OPA-input -//! schema. -//! -//! Attribute values are keyed by their `` -//! `LONG-NAME`, lowercased and snake-cased (`attribute_definition_names`), -//! the same convention `normalization.py::_normalize_spec_object` uses to -//! build its `attrs_map`. A requirement's `title`/`text` are then derived -//! from that map (falling back through `name`/`title`/`key` for `title`, -//! `text`/`description` for `text`) rather than from any ReqIF-standard -//! field, because ReqIF itself has no dedicated title/text elements — -//! everything beyond `IDENTIFIER`/`LONG-NAME` lives in vendor-defined -//! attributes. - -use std::collections::BTreeMap; - -use reqrs::ids::{AttributeDefId, EnumValueId, SpecTypeId}; -use reqrs::model::{ - AttributeDefinition, AttributeValue, DataType, ReqIfBundle, SpecObject, SpecRelation, SpecType, -}; - -use crate::requirements::{ - BaselineIdentity, Provenance, RelationAuthority, Requirement, RequirementError, - RequirementGraph, RequirementRelation, RequirementRelationKind, -}; - -/// Caller-supplied identity for the baseline a bundle represents. ReqIF has -/// no field that maps onto [`BaselineIdentity::revision`] (an "immutable -/// source revision" is a Flexo/kr0ki concept, not a ReqIF one) — the caller -/// supplies one, e.g. a git commit, an import timestamp, or a content hash -/// of the raw bytes. -#[derive(Debug, Clone)] -pub struct ReqIfAdapterConfig { - /// Opaque locator for the source artifact (file path, URI, etc.) — - /// becomes both [`Provenance::source_uri`] and, when the bundle's own - /// `` is absent, the baseline id. - pub source_uri: String, - pub revision: String, - pub import_artifact_sha256: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum ReqIfAdapterError { - #[error(" element not found")] - MissingContent, - #[error(transparent)] - Graph(#[from] RequirementError), -} - -/// Map a parsed [`ReqIfBundle`] onto a [`RequirementGraph`], validating the -/// result before returning it (see [`RequirementGraph::validate`]). -pub fn bundle_to_requirement_graph( - bundle: &ReqIfBundle, - config: &ReqIfAdapterConfig, -) -> Result { - let content = bundle - .core_content - .as_ref() - .and_then(|core| core.req_if_content.as_ref()) - .ok_or(ReqIfAdapterError::MissingContent)?; - - let baseline = BaselineIdentity { - id: bundle - .header - .as_ref() - .map(|header| header.identifier.clone()) - .filter(|id| !id.is_empty()) - .unwrap_or_else(|| config.source_uri.clone()), - revision: config.revision.clone(), - import_artifact_sha256: config.import_artifact_sha256.clone(), - exported_baseline_sha256: None, - }; - let provenance = Provenance { - source_uri: config.source_uri.clone(), - artifact_sha256: config.import_artifact_sha256.clone(), - locator: None, - }; - - let spec_types = content.spec_types.as_deref().unwrap_or(&[]); - let attr_def_names = attribute_definition_names(spec_types); - let spec_type_names = spec_type_names(spec_types); - let enum_value_names = enum_value_names(content.data_types.as_deref().unwrap_or(&[])); - - let requirements = content - .spec_objects - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|spec_object| { - spec_object_to_requirement( - spec_object, - &attr_def_names, - &spec_type_names, - &enum_value_names, - &baseline, - &provenance, - ) - }) - .collect(); - - let relations = content - .spec_relations - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|spec_relation| { - spec_relation_to_requirement_relation(spec_relation, &spec_type_names, &provenance) - }) - .collect(); - - let graph = RequirementGraph { - baseline, - requirements, - evidence: Vec::new(), - relations, - }; - graph.validate()?; - Ok(graph) -} - -fn attribute_definition_common(def: &AttributeDefinition) -> &reqrs::model::AttributeDefCommon { - match def { - AttributeDefinition::String(d) => &d.common, - AttributeDefinition::Boolean(d) => &d.common, - AttributeDefinition::Integer(d) => &d.common, - AttributeDefinition::Real(d) => &d.common, - AttributeDefinition::Date(d) => &d.common, - AttributeDefinition::Xhtml(d) => &d.common, - AttributeDefinition::Enumeration(d) => &d.common, - } -} - -fn spec_type_common(spec_type: &SpecType) -> &reqrs::model::SpecTypeCommon { - match spec_type { - SpecType::SpecObject(t) => &t.common, - SpecType::Specification(t) => &t.common, - SpecType::SpecRelation(t) => &t.common, - SpecType::RelationGroup(t) => &t.common, - } -} - -/// `` id -> normalized `LONG-NAME`, flattened across -/// every `` entry. Mirrors `attr_defs_map` in -/// `reqif_mcp/normalization.py`. -fn attribute_definition_names(spec_types: &[SpecType]) -> BTreeMap { - spec_types - .iter() - .flat_map(|spec_type| spec_type_common(spec_type).spec_attributes.iter().flatten()) - .filter_map(|def| { - let long_name = attribute_definition_common(def).long_name.as_deref()?; - Some((def.identifier().clone(), normalize_key(long_name))) - }) - .collect() -} - -/// `` id -> normalized `LONG-NAME`. Used to recover a -/// requirement's subtype and a relation's [`RequirementRelationKind`] when -/// no more specific attribute carries that information. -fn spec_type_names(spec_types: &[SpecType]) -> BTreeMap { - spec_types - .iter() - .map(|spec_type| { - let common = spec_type_common(spec_type); - let name = common.long_name.as_deref().unwrap_or_default(); - (common.identifier.clone(), normalize_key(name)) - }) - .collect() -} - -/// `` id -> its human-readable value. `LONG-NAME` is the ReqIF -/// display value; `KEY` is a vendor-stable fallback when no long name exists. -fn enum_value_names(data_types: &[DataType]) -> BTreeMap { - data_types - .iter() - .filter_map(|data_type| match data_type { - DataType::Enumeration(data_type) => data_type.specified_values.as_deref(), - _ => None, - }) - .flatten() - .filter_map(|value| { - value - .long_name - .as_deref() - .filter(|name| !name.is_empty()) - .map(str::to_owned) - .or_else(|| (!value.key.is_empty()).then(|| value.key.clone())) - .map(|name| (value.identifier.clone(), name)) - }) - .collect() -} - -fn normalize_key(s: &str) -> String { - s.to_lowercase().replace([' ', '-'], "_") -} - -fn attribute_value_string( - value: &AttributeValue, - enum_value_names: &BTreeMap, -) -> String { - match value { - AttributeValue::String(v) => v.value.clone(), - AttributeValue::Boolean(v) => v.value.to_string(), - AttributeValue::Integer(v) => v.value.clone(), - AttributeValue::Real(v) => v.value.clone(), - AttributeValue::Date(v) => v.value.clone(), - AttributeValue::Xhtml(v) => v.the_value_raw.clone(), - AttributeValue::Enumeration(v) => v - .values - .iter() - .map(|id| { - enum_value_names - .get(id) - .map(String::as_str) - .unwrap_or_else(|| id.as_str()) - }) - .collect::>() - .join(", "), - } -} - -fn spec_object_to_requirement( - spec_object: &SpecObject, - attr_def_names: &BTreeMap, - spec_type_names: &BTreeMap, - enum_value_names: &BTreeMap, - baseline: &BaselineIdentity, - provenance: &Provenance, -) -> Requirement { - let mut attributes = BTreeMap::new(); - for value in &spec_object.attributes { - if let Some(key) = attr_def_names.get(value.definition_ref()) { - attributes.insert(key.clone(), attribute_value_string(value, enum_value_names)); - } - } - - // ReqIF's required SPEC-OBJECT-TYPE-REF is the canonical subtype when a - // vendor did not provide a populated `subtypes` or `type` attribute. - if !["subtypes", "type"] - .into_iter() - .any(|key| attributes.get(key).is_some_and(|value| !value.is_empty())) - { - if let Some(name) = spec_type_names - .get(&spec_object.spec_object_type) - .filter(|name| !name.is_empty()) - { - attributes.insert("subtypes".to_string(), name.clone()); - } - } - - let title = spec_object - .long_name - .clone() - .filter(|s| !s.is_empty()) - .or_else(|| first_non_empty_attribute(&attributes, &["name", "title", "key"])) - .unwrap_or_else(|| spec_object.identifier.as_str().to_string()); - - let text = first_non_empty_attribute(&attributes, &["text", "description"]).unwrap_or_default(); - - Requirement { - id: spec_object.identifier.as_str().to_string(), - title, - text, - baseline: baseline.clone(), - provenance: provenance.clone(), - attributes, - evidence: Vec::new(), - } -} - -fn first_non_empty_attribute( - attributes: &BTreeMap, - keys: &[&str], -) -> Option { - keys.iter() - .filter_map(|key| attributes.get(*key)) - .find(|value| !value.is_empty()) - .cloned() -} - -fn spec_relation_to_requirement_relation( - spec_relation: &SpecRelation, - spec_type_names: &BTreeMap, - provenance: &Provenance, -) -> RequirementRelation { - let kind = spec_type_names - .get(&spec_relation.relation_type) - .and_then(|name| relation_kind_from_name(name)) - .unwrap_or(RequirementRelationKind::Traces); - - RequirementRelation { - id: spec_relation.identifier.as_str().to_string(), - source: spec_relation.source.as_str().to_string(), - target: spec_relation.target.as_str().to_string(), - kind, - authority: RelationAuthority::Asserted, - provenance: provenance.clone(), - promotion: None, - } -} - -/// Maps a normalized `` onto the shared -/// [`RequirementRelationKind`] vocabulary. Unrecognized names fall back to -/// [`RequirementRelationKind::Traces`] in the caller, the same "don't lose -/// the edge, don't overclaim its meaning" default -/// `normalization.py::_extract_subtypes` uses for an unknown requirement -/// subtype (`GENERAL`). -fn relation_kind_from_name(name: &str) -> Option { - use RequirementRelationKind::*; - Some(match name { - "contains" => Contains, - "derives" | "derive" | "derived_from" => Derives, - "refines" => Refines, - "requires" => Requires, - "satisfies" => Satisfies, - "verifies" => Verifies, - "implements" => Implements, - "traces" => Traces, - "allocated_to" | "allocatedto" => AllocatedTo, - "precedes" => Precedes, - _ => return None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use reqrs::ReqIfParser; - - /// Two spec objects (one carrying `Name`/`Text`/`Key` attributes, one - /// carrying only a `LONG-NAME`) plus one `derives` relation between - /// them. Exercises the full parse -> map path, not a hand-built bundle. - const FIXTURE: &str = r#" - - - - Adapter test baseline - - - - - - - - - - - - - - - - - - - DT-STRING - - - DT-STRING - - - DT-STRING - - - DT-STRING - - - DT-STRING - - - DT-STATUS - - - - - - - - ST-REQUIREMENT - - - AD-KEY - - - AD-TEXT - - - EV-ACTIVE - AD-STATUS - - - - - ST-REQUIREMENT - - - AD-NAME - - - AD-TITLE - - - AD-KEY - - - AD-TEXT - - - AD-DESCRIPTION - - - - - - - ST-DERIVES - REQ-2 - REQ-1 - - - - -"#; - - fn config() -> ReqIfAdapterConfig { - ReqIfAdapterConfig { - source_uri: "test://fixture.reqif".to_string(), - revision: "rev-1".to_string(), - import_artifact_sha256: Some("deadbeef".to_string()), - } - } - - #[test] - fn maps_baseline_from_header_identifier() { - let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - assert_eq!(graph.baseline.id, "BASELINE-TEST"); - assert_eq!(graph.baseline.revision, "rev-1"); - assert_eq!( - graph.baseline.import_artifact_sha256.as_deref(), - Some("deadbeef") - ); - } - - #[test] - fn maps_spec_object_long_name_and_attributes_to_title_and_text() { - let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - - let req1 = graph.requirements.iter().find(|r| r.id == "REQ-1").unwrap(); - assert_eq!(req1.title, "Encryption at rest"); - assert_eq!(req1.text, "System shall encrypt data at rest."); - assert_eq!( - req1.attributes.get("key").map(String::as_str), - Some("ENC-001") - ); - assert_eq!( - req1.attributes.get("status").map(String::as_str), - Some("Active") - ); - assert_eq!(req1.provenance.source_uri, "test://fixture.reqif"); - } - - #[test] - fn retains_object_type_as_subtype_when_vendor_attributes_are_absent() { - let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - - let req2 = graph.requirements.iter().find(|r| r.id == "REQ-2").unwrap(); - assert_eq!( - req2.attributes.get("subtypes").map(String::as_str), - Some("requirement") - ); - } - - #[test] - fn skips_empty_title_and_text_attributes_when_falling_back() { - let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - - let req2 = graph.requirements.iter().find(|r| r.id == "REQ-2").unwrap(); - assert_eq!(req2.title, "Derived encryption"); - assert_eq!(req2.text, "Derived encryption must remain protected."); - } - - #[test] - fn uses_enum_key_then_identifier_when_long_name_is_unavailable() { - let bundle = ReqIfParser::parse_str(&FIXTURE.replace(r#"LONG-NAME="Active""#, "")).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - assert_eq!( - graph.requirements[0] - .attributes - .get("status") - .map(String::as_str), - Some("active") - ); - - let bundle = ReqIfParser::parse_str(&FIXTURE.replace( - ">EV-ACTIVE", - ">EV-UNKNOWN", - )) - .unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - assert_eq!( - graph.requirements[0] - .attributes - .get("status") - .map(String::as_str), - Some("EV-UNKNOWN") - ); - } - - #[test] - fn maps_spec_relation_to_requirement_relation_with_kind_from_relation_type() { - let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - - assert_eq!(graph.relations.len(), 1); - let rel = &graph.relations[0]; - assert_eq!(rel.id, "REL-1"); - assert_eq!(rel.source, "REQ-2"); - assert_eq!(rel.target, "REQ-1"); - assert_eq!(rel.kind, RequirementRelationKind::Derives); - assert!(rel.authority.is_asserted()); - } - - #[test] - fn returned_graph_passes_validation() { - let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - assert!(graph.validate().is_ok()); - } - - #[test] - fn missing_content_is_a_typed_error() { - let bundle = ReqIfParser::parse_str( - r#" - - -"#, - ) - .unwrap(); - let err = bundle_to_requirement_graph(&bundle, &config()).unwrap_err(); - assert!(matches!(err, ReqIfAdapterError::MissingContent)); - } - - #[test] - fn unrecognized_relation_type_name_falls_back_to_traces() { - let xml = FIXTURE.replace( - r#"LONG-NAME="Derives""#, - r#"LONG-NAME="Custom Vendor Link""#, - ); - let bundle = ReqIfParser::parse_str(&xml).unwrap(); - let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); - assert_eq!(graph.relations[0].kind, RequirementRelationKind::Traces); - } -} diff --git a/crates/kr0ki-core/src/reqif_import.rs b/crates/kr0ki-core/src/reqif_import.rs index b0367dd..09284c8 100644 --- a/crates/kr0ki-core/src/reqif_import.rs +++ b/crates/kr0ki-core/src/reqif_import.rs @@ -13,10 +13,8 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use zip::ZipArchive; -use crate::{ - reqif_adapter::{bundle_to_requirement_graph, ReqIfAdapterConfig, ReqIfAdapterError}, - requirements::RequirementGraph, -}; +use crate::requirements::RequirementGraph; +use ufo_types::reqif::{parse_and_lower, ReqIfAdapterConfig, ReqIfAdapterError}; /// Default cap for both the uploaded artifact and an expanded ReqIFz archive. /// @@ -126,8 +124,6 @@ pub enum ReqIfImportError { #[error("invalid ReqIFz archive: {0}")] Archive(String), #[error(transparent)] - Parse(#[from] reqrs::ReqIfError), - #[error(transparent)] Adapter(#[from] ReqIfAdapterError), #[error("could not serialize the normalized requirements graph: {0}")] CanonicalGraph(#[from] serde_json::Error), @@ -275,9 +271,8 @@ fn parse_document( .revision .clone() .unwrap_or_else(|| format!("sha256:{artifact_sha256}")); - let bundle = reqrs::ReqIfParser::parse_bytes(bytes)?; - let graph = bundle_to_requirement_graph( - &bundle, + let graph = parse_and_lower( + bytes, &ReqIfAdapterConfig { source_uri, revision, @@ -366,7 +361,7 @@ mod tests { fn rejects_malformed_xml_with_a_typed_parse_error() { let error = import_reqif_artifact(b"", &ReqIfImportConfig::default()) .unwrap_err(); - assert!(matches!(error, ReqIfImportError::Parse(_))); + assert!(matches!(error, ReqIfImportError::Adapter(_))); } #[test] From 167da540b43a24820426fdc69318083da3aed6c0 Mon Sep 17 00:00:00 2001 From: Brian H Date: Sun, 20 Sep 2026 10:26:23 +0000 Subject: [PATCH 3/3] build: pin ufo-types to the pushed feat/mbse-requirements rev Drops the local-path [patch] used while a1f6a89 was uncommitted/ unpushed -- ufo-types@a1f6a89 is now pushed, so kr0ki-core pins it by rev directly (no tag exists past v0.15.0 yet covering this commit). Verified: cargo build -p kr0ki-core and cargo test -p kr0ki-core --lib (156/156) both pass against the real git dependency, no local override. --- Cargo.lock | 11 +++++++++-- Cargo.toml | 1 + crates/kr0ki-core/Cargo.toml | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 619d228..30dde54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1077,7 +1077,6 @@ dependencies = [ "oxrdf", "oxttl", "quote", - "reqrs", "reqwest", "resvg", "serde", @@ -1869,6 +1868,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -2471,12 +2476,14 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ufo-types" version = "0.15.0" -source = "git+https://github.com/PromptExecution/ufo-types?tag=v0.15.0#a17bf7444bba77fd049a25e943f44c8b0a8afa77" +source = "git+https://github.com/PromptExecution/ufo-types?rev=a1f6a89bb5b0f63352ff00ac7c1eaf6acef8a4af#a1f6a89bb5b0f63352ff00ac7c1eaf6acef8a4af" dependencies = [ "anyhow", "chrono", + "reqrs", "rpassword", "schemars", + "semver", "serde", "serde_json", "shellexpand", diff --git a/Cargo.toml b/Cargo.toml index 6974104..30c8aa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,4 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" axum = "0.7" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + diff --git a/crates/kr0ki-core/Cargo.toml b/crates/kr0ki-core/Cargo.toml index 0868fcb..78423bf 100644 --- a/crates/kr0ki-core/Cargo.toml +++ b/crates/kr0ki-core/Cargo.toml @@ -27,7 +27,10 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" } # CHANGELOG guidance ("pin an exact version or a git tag"). `ontology` / # `sysml_model` / `view` are default (not feature-gated) — no extra features # needed here. -ufo-types = { git = "https://github.com/PromptExecution/ufo-types", tag = "v0.15.0", features = ["reqif"] } +# Pinned to the feat/mbse-requirements branch tip (not yet a tagged +# release) -- bump to a `tag =` pin once ufo-types cuts one past v0.15.0 +# including this commit. +ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "a1f6a89bb5b0f63352ff00ac7c1eaf6acef8a4af", features = ["reqif"] } # kr0ki#13 — box 5 (b00t-graph Turtle -> D2 route): the typed TypeRelationshipGraph / # CytoscapeGraph the D2 emitter targets. No tagged release exists yet (checked # 2026-09-16); pinned by git rev per this issue's own operator decision, to be