From 67f87d53ddf199264403000d10bcd3c123520d82 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:50:15 +0300 Subject: [PATCH 01/11] refactor(pkg): expose lock v2 validation inside crate --- crates/commandf-pkg/src/lock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/commandf-pkg/src/lock.rs b/crates/commandf-pkg/src/lock.rs index 84acd79b..0e0b4631 100644 --- a/crates/commandf-pkg/src/lock.rs +++ b/crates/commandf-pkg/src/lock.rs @@ -157,7 +157,7 @@ impl Lockfile { Ok(()) } - fn validate_v2(&self) -> Result<(), PackageError> { + pub(crate) fn validate_v2(&self) -> Result<(), PackageError> { let mut canonical_roots = self.roots.clone(); canonical_roots.sort(); canonical_roots.dedup(); From bee84b446699bbff7e1cedc962c4988b8173c802 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:50:27 +0300 Subject: [PATCH 02/11] feat(pkg): return verified cache bytes atomically --- crates/commandf-pkg/src/cache.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/commandf-pkg/src/cache.rs b/crates/commandf-pkg/src/cache.rs index 57f09791..325bfaf8 100644 --- a/crates/commandf-pkg/src/cache.rs +++ b/crates/commandf-pkg/src/cache.rs @@ -61,6 +61,10 @@ impl PackageCache { } pub fn verify(&self, digest: &str) -> Result<(), PackageError> { + self.read_verified(digest).map(|_| ()) + } + + pub fn read_verified(&self, digest: &str) -> Result, PackageError> { validate_digest(digest)?; let path = self.object_path(digest); let bytes = fs::read(&path).map_err(|error| { @@ -78,7 +82,7 @@ impl PackageCache { found, }); } - Ok(()) + Ok(bytes) } } From d82f74f0c37a45ef2b4ababe83176c30d390e97b Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:50:42 +0300 Subject: [PATCH 03/11] feat(context): add deterministic context graph model --- crates/commandf-pkg/src/context_model.rs | 99 ++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 crates/commandf-pkg/src/context_model.rs diff --git a/crates/commandf-pkg/src/context_model.rs b/crates/commandf-pkg/src/context_model.rs new file mode 100644 index 00000000..906cec0e --- /dev/null +++ b/crates/commandf-pkg/src/context_model.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextPackageIdentity { + pub name: String, + pub version: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextPackageNode { + pub identity: ContextPackageIdentity, + pub source: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextArtifactIdentity { + pub package: ContextPackageIdentity, + pub filename: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextArtifactNode { + pub identity: ContextArtifactIdentity, + pub resource_type: String, + pub id: Option, + pub canonical_url: Option, + pub canonical_version: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextPackageDependencyEdge { + pub from: ContextPackageIdentity, + pub to: ContextPackageIdentity, + pub declared_constraint: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanonicalReferenceRelation { + StructureBaseDefinition, + StructureTypeProfile, + StructureTypeTargetProfile, + StructureBindingValueSet, + ValueSetIncludeSystem, + ValueSetIncludeValueSet, + ValueSetExcludeSystem, + ValueSetExcludeValueSet, + CodeSystemSupplements, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanonicalResolutionStatus { + Resolved, + External, + Ambiguous, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextCanonicalReferenceEdge { + pub source: ContextArtifactIdentity, + pub relation: CanonicalReferenceRelation, + pub source_path: String, + pub source_element_id: Option, + pub canonical: String, + pub resolution: CanonicalResolutionStatus, + pub candidates: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ContextCoverage { + pub extractor_schema: u32, + pub supported_source_resource_types: Vec, + pub unsupported_source_resource_types: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ContextGraphReport { + pub schema: u32, + pub lock_schema: u32, + pub root_requests: Vec, + pub packages: Vec, + pub artifacts: Vec, + pub package_dependency_edges: Vec, + pub canonical_reference_edges: Vec, + pub coverage: ContextCoverage, +} + +impl ContextGraphReport { + pub const SCHEMA_V1: u32 = 1; + + pub fn to_json_bytes(&self) -> Result, serde_json::Error> { + let mut bytes = serde_json::to_vec_pretty(self)?; + bytes.push(b'\n'); + Ok(bytes) + } +} From 1d8a4c7ac007f0a1f825ebb7011a5403219e2266 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:50:51 +0300 Subject: [PATCH 04/11] feat(context): add context graph fail-closed errors --- crates/commandf-pkg/src/context_error.rs | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 crates/commandf-pkg/src/context_error.rs diff --git a/crates/commandf-pkg/src/context_error.rs b/crates/commandf-pkg/src/context_error.rs new file mode 100644 index 00000000..012c8972 --- /dev/null +++ b/crates/commandf-pkg/src/context_error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +use crate::{ArtifactError, PackageError}; + +#[derive(Debug, Error)] +pub enum ContextGraphError { + #[error("commandf context requires commandf.lock schema 2; found schema {found}")] + RequiresLockV2 { found: u32 }, + #[error("context graph package cache error: {0}")] + Package(#[from] PackageError), + #[error("context graph artifact inspection error: {0}")] + Artifact(#[from] ArtifactError), + #[error("context graph resource {file} field {path} must be {expected}")] + InvalidResourceField { + file: String, + path: String, + expected: &'static str, + }, + #[error("context graph artifact inventory mismatch for {file}")] + ArtifactInventoryMismatch { file: String }, + #[error("context graph canonical reference in {file} at {path} has an empty target URL")] + EmptyCanonicalTarget { file: String, path: String }, + #[error("context graph canonical reference in {file} at {path} has an empty explicit version")] + EmptyCanonicalVersion { file: String, path: String }, +} From 872db6c2dc1faa7af08524b589dba21d0c5e5d37 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:51:48 +0300 Subject: [PATCH 05/11] feat(context): build deterministic ecosystem context graph --- crates/commandf-pkg/src/context.rs | 581 +++++++++++++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 crates/commandf-pkg/src/context.rs diff --git a/crates/commandf-pkg/src/context.rs b/crates/commandf-pkg/src/context.rs new file mode 100644 index 00000000..5d8af6db --- /dev/null +++ b/crates/commandf-pkg/src/context.rs @@ -0,0 +1,581 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +use crate::artifact_scan::scan_package_resources; +use crate::{ + inspect_package, ArtifactError, CanonicalReferenceRelation, CanonicalResolutionStatus, + ContextArtifactIdentity, ContextArtifactNode, ContextCanonicalReferenceEdge, ContextCoverage, + ContextGraphError, ContextGraphReport, ContextPackageDependencyEdge, ContextPackageIdentity, + ContextPackageNode, Lockfile, PackageCache, +}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct PendingCanonicalReference { + source: ContextArtifactIdentity, + relation: CanonicalReferenceRelation, + source_path: String, + source_element_id: Option, + canonical: String, +} + +pub fn build_context_graph( + lock: &Lockfile, + cache: &PackageCache, +) -> Result { + if lock.schema != Lockfile::SCHEMA_V2 { + return Err(ContextGraphError::RequiresLockV2 { found: lock.schema }); + } + lock.validate_v2()?; + + let mut packages = Vec::new(); + let mut package_identities = BTreeMap::new(); + for package in &lock.packages { + let identity = ContextPackageIdentity { + name: package.name.clone(), + version: package.version.clone(), + sha256: package.sha256.clone(), + }; + package_identities.insert( + (package.name.clone(), package.version.clone()), + identity.clone(), + ); + packages.push(ContextPackageNode { + identity, + source: package.source.clone(), + }); + } + packages.sort(); + + let mut package_dependency_edges = Vec::new(); + for edge in &lock.resolved_dependencies { + let from = package_identities + .get(&(edge.from_name.clone(), edge.from_version.clone())) + .cloned() + .ok_or_else(|| crate::PackageError::InvalidLockfile(format!( + "context graph source package {}@{} disappeared after lock validation", + edge.from_name, edge.from_version + )))?; + let to = package_identities + .get(&(edge.to_name.clone(), edge.to_version.clone())) + .cloned() + .ok_or_else(|| crate::PackageError::InvalidLockfile(format!( + "context graph target package {}@{} disappeared after lock validation", + edge.to_name, edge.to_version + )))?; + package_dependency_edges.push(ContextPackageDependencyEdge { + from, + to, + declared_constraint: edge.declared_constraint.clone(), + }); + } + package_dependency_edges.sort(); + package_dependency_edges.dedup(); + + let mut artifacts = Vec::new(); + let mut pending_references = Vec::new(); + let mut present_resource_types = BTreeSet::new(); + + for package in &lock.packages { + let archive_bytes = cache.read_verified(&package.sha256)?; + let inspection = inspect_package( + package.name.clone(), + package.version.clone(), + package.sha256.clone(), + &archive_bytes, + )?; + let scanned_resources = scan_package_resources(&archive_bytes)?; + let inspected_by_filename = inspection + .resources + .iter() + .map(|resource| (resource.filename.as_str(), resource)) + .collect::>(); + + for scanned in scanned_resources { + let inspected = inspected_by_filename + .get(scanned.filename.as_str()) + .ok_or_else(|| ContextGraphError::ArtifactInventoryMismatch { + file: scanned.filename.clone(), + })?; + present_resource_types.insert(inspected.resource_type.clone()); + + let package_identity = package_identities + .get(&(package.name.clone(), package.version.clone())) + .cloned() + .ok_or_else(|| crate::PackageError::InvalidLockfile(format!( + "context graph package {}@{} disappeared after lock validation", + package.name, package.version + )))?; + let artifact_identity = ContextArtifactIdentity { + package: package_identity, + filename: inspected.filename.clone(), + sha256: inspected.sha256.clone(), + }; + artifacts.push(ContextArtifactNode { + identity: artifact_identity.clone(), + resource_type: inspected.resource_type.clone(), + id: inspected.id.clone(), + canonical_url: inspected.canonical_url.clone(), + canonical_version: inspected.canonical_version.clone(), + }); + + let value: Value = serde_json::from_slice(&scanned.bytes).map_err(|source| { + ContextGraphError::Artifact(ArtifactError::Json { + file: scanned.filename.clone(), + source, + }) + })?; + let object = value.as_object().ok_or_else(|| invalid_field( + &scanned.filename, + "$", + "a JSON object", + ))?; + extract_references( + &artifact_identity, + &scanned.filename, + &inspected.resource_type, + object, + &mut pending_references, + )?; + } + } + + artifacts.sort(); + artifacts.dedup(); + pending_references.sort(); + pending_references.dedup(); + + let canonical_index = build_canonical_index(&artifacts); + let mut canonical_reference_edges = Vec::new(); + for pending in pending_references { + canonical_reference_edges.push(resolve_reference(pending, &canonical_index)?); + } + canonical_reference_edges.sort(); + canonical_reference_edges.dedup(); + + let supported_source_resource_types = vec![ + "CodeSystem".to_owned(), + "StructureDefinition".to_owned(), + "ValueSet".to_owned(), + ]; + let supported = supported_source_resource_types + .iter() + .map(String::as_str) + .collect::>(); + let unsupported_source_resource_types = present_resource_types + .into_iter() + .filter(|resource_type| !supported.contains(resource_type.as_str())) + .collect::>(); + + Ok(ContextGraphReport { + schema: ContextGraphReport::SCHEMA_V1, + lock_schema: lock.schema, + root_requests: lock.roots.clone(), + packages, + artifacts, + package_dependency_edges, + canonical_reference_edges, + coverage: ContextCoverage { + extractor_schema: 1, + supported_source_resource_types, + unsupported_source_resource_types, + }, + }) +} + +fn build_canonical_index( + artifacts: &[ContextArtifactNode], +) -> BTreeMap, ContextArtifactIdentity)>> { + let mut index = BTreeMap::, ContextArtifactIdentity)>>::new(); + for artifact in artifacts { + let Some(url) = &artifact.canonical_url else { + continue; + }; + index.entry(url.clone()).or_default().push(( + artifact.canonical_version.clone(), + artifact.identity.clone(), + )); + } + for candidates in index.values_mut() { + candidates.sort(); + candidates.dedup(); + } + index +} + +fn resolve_reference( + pending: PendingCanonicalReference, + index: &BTreeMap, ContextArtifactIdentity)>>, +) -> Result { + let (target_url, explicit_version) = parse_canonical_target( + &pending.canonical, + &pending.source.filename, + &pending.source_path, + )?; + let mut candidates = index + .get(target_url) + .into_iter() + .flatten() + .filter(|(candidate_version, _)| match explicit_version { + Some(version) => candidate_version.as_deref() == Some(version), + None => true, + }) + .map(|(_, identity)| identity.clone()) + .collect::>(); + candidates.sort(); + candidates.dedup(); + + let resolution = match candidates.len() { + 0 => CanonicalResolutionStatus::External, + 1 => CanonicalResolutionStatus::Resolved, + _ => CanonicalResolutionStatus::Ambiguous, + }; + + Ok(ContextCanonicalReferenceEdge { + source: pending.source, + relation: pending.relation, + source_path: pending.source_path, + source_element_id: pending.source_element_id, + canonical: pending.canonical, + resolution, + candidates, + }) +} + +fn parse_canonical_target<'a>( + canonical: &'a str, + file: &str, + path: &str, +) -> Result<(&'a str, Option<&'a str>), ContextGraphError> { + let without_fragment = canonical + .split_once('#') + .map(|(target, _)| target) + .unwrap_or(canonical); + if without_fragment.is_empty() { + return Err(ContextGraphError::EmptyCanonicalTarget { + file: file.to_owned(), + path: path.to_owned(), + }); + } + if let Some((url, version)) = without_fragment.rsplit_once('|') { + if url.is_empty() { + return Err(ContextGraphError::EmptyCanonicalTarget { + file: file.to_owned(), + path: path.to_owned(), + }); + } + if version.is_empty() { + return Err(ContextGraphError::EmptyCanonicalVersion { + file: file.to_owned(), + path: path.to_owned(), + }); + } + Ok((url, Some(version))) + } else { + Ok((without_fragment, None)) + } +} + +fn extract_references( + source: &ContextArtifactIdentity, + file: &str, + resource_type: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + match resource_type { + "StructureDefinition" => extract_structure_definition(source, file, object, output), + "ValueSet" => extract_value_set(source, file, object, output), + "CodeSystem" => extract_code_system(source, file, object, output), + _ => Ok(()), + } +} + +fn extract_structure_definition( + source: &ContextArtifactIdentity, + file: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + if let Some(canonical) = optional_string(object, "baseDefinition", file, "baseDefinition")? { + push_reference( + output, + source, + CanonicalReferenceRelation::StructureBaseDefinition, + "baseDefinition".to_owned(), + None, + canonical, + ); + } + + let Some(differential) = optional_object(object, "differential", file, "differential")? else { + return Ok(()); + }; + let elements = required_array(differential, "element", file, "differential.element")?; + for (element_index, element) in elements.iter().enumerate() { + let element_path = format!("differential.element[{element_index}]"); + let element = element.as_object().ok_or_else(|| { + invalid_field(file, &element_path, "a JSON object") + })?; + let element_id = required_string(element, "id", file, &format!("{element_path}.id"))?; + + if let Some(types) = optional_array(element, "type", file, &format!("{element_path}.type"))? { + for (type_index, type_value) in types.iter().enumerate() { + let type_path = format!("{element_path}.type[{type_index}]"); + let type_object = type_value + .as_object() + .ok_or_else(|| invalid_field(file, &type_path, "a JSON object"))?; + extract_canonical_array( + output, + source, + type_object, + "profile", + file, + &type_path, + &element_id, + CanonicalReferenceRelation::StructureTypeProfile, + )?; + extract_canonical_array( + output, + source, + type_object, + "targetProfile", + file, + &type_path, + &element_id, + CanonicalReferenceRelation::StructureTypeTargetProfile, + )?; + } + } + + if let Some(binding) = optional_object( + element, + "binding", + file, + &format!("{element_path}.binding"), + )? { + if let Some(canonical) = optional_string( + binding, + "valueSet", + file, + &format!("{element_path}.binding.valueSet"), + )? { + push_reference( + output, + source, + CanonicalReferenceRelation::StructureBindingValueSet, + format!("{element_path}.binding.valueSet"), + Some(element_id.clone()), + canonical, + ); + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn extract_canonical_array( + output: &mut Vec, + source: &ContextArtifactIdentity, + object: &Map, + field: &str, + file: &str, + parent_path: &str, + element_id: &str, + relation: CanonicalReferenceRelation, +) -> Result<(), ContextGraphError> { + let field_path = format!("{parent_path}.{field}"); + let Some(values) = optional_array(object, field, file, &field_path)? else { + return Ok(()); + }; + for (index, value) in values.iter().enumerate() { + let source_path = format!("{field_path}[{index}]"); + let canonical = value + .as_str() + .ok_or_else(|| invalid_field(file, &source_path, "a string"))?; + push_reference( + output, + source, + relation, + source_path, + Some(element_id.to_owned()), + canonical.to_owned(), + ); + } + Ok(()) +} + +fn extract_value_set( + source: &ContextArtifactIdentity, + file: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + let Some(compose) = optional_object(object, "compose", file, "compose")? else { + return Ok(()); + }; + for (field, system_relation, value_set_relation) in [ + ( + "include", + CanonicalReferenceRelation::ValueSetIncludeSystem, + CanonicalReferenceRelation::ValueSetIncludeValueSet, + ), + ( + "exclude", + CanonicalReferenceRelation::ValueSetExcludeSystem, + CanonicalReferenceRelation::ValueSetExcludeValueSet, + ), + ] { + let field_path = format!("compose.{field}"); + let Some(clauses) = optional_array(compose, field, file, &field_path)? else { + continue; + }; + for (clause_index, clause) in clauses.iter().enumerate() { + let clause_path = format!("{field_path}[{clause_index}]"); + let clause = clause + .as_object() + .ok_or_else(|| invalid_field(file, &clause_path, "a JSON object"))?; + if let Some(system) = optional_string( + clause, + "system", + file, + &format!("{clause_path}.system"), + )? { + push_reference( + output, + source, + system_relation, + format!("{clause_path}.system"), + None, + system, + ); + } + let value_set_path = format!("{clause_path}.valueSet"); + if let Some(imports) = optional_array(clause, "valueSet", file, &value_set_path)? { + for (import_index, import) in imports.iter().enumerate() { + let source_path = format!("{value_set_path}[{import_index}]"); + let canonical = import + .as_str() + .ok_or_else(|| invalid_field(file, &source_path, "a string"))?; + push_reference( + output, + source, + value_set_relation, + source_path, + None, + canonical.to_owned(), + ); + } + } + } + } + Ok(()) +} + +fn extract_code_system( + source: &ContextArtifactIdentity, + file: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + if let Some(canonical) = optional_string(object, "supplements", file, "supplements")? { + push_reference( + output, + source, + CanonicalReferenceRelation::CodeSystemSupplements, + "supplements".to_owned(), + None, + canonical, + ); + } + Ok(()) +} + +fn push_reference( + output: &mut Vec, + source: &ContextArtifactIdentity, + relation: CanonicalReferenceRelation, + source_path: String, + source_element_id: Option, + canonical: String, +) { + output.push(PendingCanonicalReference { + source: source.clone(), + relation, + source_path, + source_element_id, + canonical, + }); +} + +fn optional_string( + object: &Map, + field: &str, + file: &str, + path: &str, +) -> Result, ContextGraphError> { + match object.get(field) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(invalid_field(file, path, "a string")), + } +} + +fn required_string( + object: &Map, + field: &str, + file: &str, + path: &str, +) -> Result { + optional_string(object, field, file, path)? + .ok_or_else(|| invalid_field(file, path, "a string")) +} + +fn optional_array<'a>( + object: &'a Map, + field: &str, + file: &str, + path: &str, +) -> Result>, ContextGraphError> { + match object.get(field) { + None => Ok(None), + Some(Value::Array(values)) => Ok(Some(values)), + Some(_) => Err(invalid_field(file, path, "an array")), + } +} + +fn required_array<'a>( + object: &'a Map, + field: &str, + file: &str, + path: &str, +) -> Result<&'a Vec, ContextGraphError> { + optional_array(object, field, file, path)? + .ok_or_else(|| invalid_field(file, path, "an array")) +} + +fn optional_object<'a>( + object: &'a Map, + field: &str, + file: &str, + path: &str, +) -> Result>, ContextGraphError> { + match object.get(field) { + None => Ok(None), + Some(Value::Object(value)) => Ok(Some(value)), + Some(_) => Err(invalid_field(file, path, "a JSON object")), + } +} + +fn invalid_field( + file: &str, + path: &str, + expected: &'static str, +) -> ContextGraphError { + ContextGraphError::InvalidResourceField { + file: file.to_owned(), + path: path.to_owned(), + expected, + } +} From d3802487d1a7bb1d10b469c40d0b047d41ea6f5d Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:52:08 +0300 Subject: [PATCH 06/11] feat(context): export context graph library --- crates/commandf-pkg/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/commandf-pkg/src/lib.rs b/crates/commandf-pkg/src/lib.rs index 1c8722a8..3c1a1b91 100644 --- a/crates/commandf-pkg/src/lib.rs +++ b/crates/commandf-pkg/src/lib.rs @@ -19,6 +19,9 @@ mod compatibility; mod compatibility_error; mod compatibility_model; mod compatibility_validate; +mod context; +mod context_error; +mod context_model; mod error; mod lock; mod model; @@ -63,6 +66,13 @@ pub use compatibility_model::{ CompatibilityDirection, CompatibilityFinding, CompatibilityReport, CompatibilitySeverity, }; pub use compatibility_validate::classify_structural_diff; +pub use context::build_context_graph; +pub use context_error::ContextGraphError; +pub use context_model::{ + CanonicalReferenceRelation, CanonicalResolutionStatus, ContextArtifactIdentity, + ContextArtifactNode, ContextCanonicalReferenceEdge, ContextCoverage, ContextGraphReport, + ContextPackageDependencyEdge, ContextPackageIdentity, ContextPackageNode, +}; pub use error::PackageError; pub use lock::{LockedPackage, Lockfile, ResolvedDependency}; pub use model::{PackageName, PackageRequest, VersionConstraint}; From 5a6cc74b8c8ab2fc6183df27d415e39b1dec48bc Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 12:52:55 +0300 Subject: [PATCH 07/11] test(context): prove deterministic graph extraction and fail-closed states --- crates/commandf-pkg/tests/context_graph.rs | 382 +++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 crates/commandf-pkg/tests/context_graph.rs diff --git a/crates/commandf-pkg/tests/context_graph.rs b/crates/commandf-pkg/tests/context_graph.rs new file mode 100644 index 00000000..e860dd54 --- /dev/null +++ b/crates/commandf-pkg/tests/context_graph.rs @@ -0,0 +1,382 @@ +use std::collections::BTreeMap; +use std::io::Cursor; + +use commandf_pkg::{ + build_context_graph, CanonicalReferenceRelation, CanonicalResolutionStatus, ContextGraphError, + LockedPackage, Lockfile, PackageCache, PackageError, ResolvedDependency, +}; +use flate2::write::GzEncoder; +use flate2::Compression; +use tar::{Builder, Header}; +use tempfile::tempdir; + +#[test] +fn builds_deterministic_context_graph_with_explicit_resolution_states() { + let refs_archive = package_archive( + "acme.refs", + "1.0.0", + &[ + ( + "package/StructureDefinition-profile.json", + br#"{ + "resourceType":"StructureDefinition", + "id":"profile", + "url":"https://example.org/StructureDefinition/profile", + "version":"1.0.0", + "baseDefinition":"https://example.org/StructureDefinition/base|1.0.0", + "differential":{"element":[{ + "id":"Observation.subject", + "type":[{ + "code":"Reference", + "profile":["https://example.org/StructureDefinition/type|1.0.0"], + "targetProfile":["https://example.org/StructureDefinition/target"] + }], + "binding":{"valueSet":"https://example.org/ValueSet/binding#allowed"} + }]} + }"#, + ), + ( + "package/ValueSet-refs.json", + br#"{ + "resourceType":"ValueSet", + "id":"refs", + "url":"https://example.org/ValueSet/refs", + "version":"1.0.0", + "compose":{ + "include":[{ + "system":"https://example.org/CodeSystem/system", + "valueSet":["https://example.org/ValueSet/imported"] + }], + "exclude":[{ + "system":"https://example.org/CodeSystem/missing", + "valueSet":["https://example.org/ValueSet/imported|2.0.0"] + }] + } + }"#, + ), + ( + "package/CodeSystem-supplement.json", + br#"{ + "resourceType":"CodeSystem", + "id":"supplement", + "url":"https://example.org/CodeSystem/supplement", + "version":"1.0.0", + "supplements":"https://example.org/CodeSystem/base|1.0.0" + }"#, + ), + ( + "package/Patient-unsupported.json", + br#"{"resourceType":"Patient","id":"unsupported"}"#, + ), + ], + ); + let targets_archive = package_archive( + "acme.targets", + "1.0.0", + &[ + canonical_resource( + "StructureDefinition", + "base", + "https://example.org/StructureDefinition/base", + "1.0.0", + ), + canonical_resource( + "StructureDefinition", + "type", + "https://example.org/StructureDefinition/type", + "1.0.0", + ), + canonical_resource( + "StructureDefinition", + "target", + "https://example.org/StructureDefinition/target", + "1.0.0", + ), + canonical_resource( + "ValueSet", + "binding", + "https://example.org/ValueSet/binding", + "1.0.0", + ), + canonical_resource( + "CodeSystem", + "system", + "https://example.org/CodeSystem/system", + "1.0.0", + ), + canonical_resource( + "ValueSet", + "imported-v1", + "https://example.org/ValueSet/imported", + "1.0.0", + ), + canonical_resource( + "ValueSet", + "imported-v2", + "https://example.org/ValueSet/imported", + "2.0.0", + ), + canonical_resource( + "CodeSystem", + "base-system", + "https://example.org/CodeSystem/base", + "1.0.0", + ), + ], + ); + + let dir = tempdir().unwrap(); + let cache = PackageCache::new(dir.path()); + let refs_sha = cache.put(&refs_archive).unwrap(); + let targets_sha = cache.put(&targets_archive).unwrap(); + let mut refs_dependencies = BTreeMap::new(); + refs_dependencies.insert("acme.targets".to_owned(), "1.0.0".to_owned()); + let lock = Lockfile::new_v2( + vec!["acme.refs@1.0.0".to_owned()], + vec![ + locked_package( + "acme.refs", + "1.0.0", + &refs_sha, + refs_dependencies, + ), + locked_package( + "acme.targets", + "1.0.0", + &targets_sha, + BTreeMap::new(), + ), + ], + vec![ResolvedDependency { + from_name: "acme.refs".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.targets".to_owned(), + to_version: "1.0.0".to_owned(), + declared_constraint: "1.0.0".to_owned(), + }], + ); + + let first = build_context_graph(&lock, &cache).unwrap(); + let second = build_context_graph(&lock, &cache).unwrap(); + assert_eq!(first.to_json_bytes().unwrap(), second.to_json_bytes().unwrap()); + assert_eq!(first.schema, 1); + assert_eq!(first.lock_schema, Lockfile::SCHEMA_V2); + assert_eq!(first.packages.len(), 2); + assert_eq!(first.package_dependency_edges.len(), 1); + assert_eq!(first.coverage.unsupported_source_resource_types, vec!["Patient"]); + + assert_resolution( + &first, + CanonicalReferenceRelation::StructureBaseDefinition, + "https://example.org/StructureDefinition/base|1.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::StructureTypeProfile, + "https://example.org/StructureDefinition/type|1.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::StructureTypeTargetProfile, + "https://example.org/StructureDefinition/target", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::StructureBindingValueSet, + "https://example.org/ValueSet/binding#allowed", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetIncludeSystem, + "https://example.org/CodeSystem/system", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetIncludeValueSet, + "https://example.org/ValueSet/imported", + CanonicalResolutionStatus::Ambiguous, + 2, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetExcludeSystem, + "https://example.org/CodeSystem/missing", + CanonicalResolutionStatus::External, + 0, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetExcludeValueSet, + "https://example.org/ValueSet/imported|2.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::CodeSystemSupplements, + "https://example.org/CodeSystem/base|1.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); +} + +#[test] +fn rejects_schema_v1_instead_of_inferring_resolved_edges() { + let lock = Lockfile::new(Vec::new(), Vec::new()); + let dir = tempdir().unwrap(); + let error = build_context_graph(&lock, &PackageCache::new(dir.path())).unwrap_err(); + + assert!(matches!( + error, + ContextGraphError::RequiresLockV2 { + found: Lockfile::SCHEMA_V1 + } + )); +} + +#[test] +fn fails_closed_when_cached_archive_bytes_are_corrupted() { + let archive = package_archive("acme.root", "1.0.0", &[]); + let dir = tempdir().unwrap(); + let cache = PackageCache::new(dir.path()); + let digest = cache.put(&archive).unwrap(); + let lock = Lockfile::new_v2( + vec!["acme.root@1.0.0".to_owned()], + vec![locked_package( + "acme.root", + "1.0.0", + &digest, + BTreeMap::new(), + )], + vec![], + ); + let object_path = dir.path().join("sha256").join(format!("{digest}.tgz")); + std::fs::write(object_path, b"corrupted").unwrap(); + + let error = build_context_graph(&lock, &cache).unwrap_err(); + assert!(matches!( + error, + ContextGraphError::Package(PackageError::CacheDigestMismatch { .. }) + )); +} + +#[test] +fn rejects_malformed_supported_reference_shape() { + let archive = package_archive( + "acme.root", + "1.0.0", + &[( + "package/StructureDefinition-bad.json", + br#"{ + "resourceType":"StructureDefinition", + "id":"bad", + "url":"https://example.org/StructureDefinition/bad", + "version":"1.0.0", + "differential":{"element":[{ + "id":"Observation.subject", + "type":[{"code":"Reference","profile":"not-an-array"}] + }]} + }"#, + )], + ); + let dir = tempdir().unwrap(); + let cache = PackageCache::new(dir.path()); + let digest = cache.put(&archive).unwrap(); + let lock = Lockfile::new_v2( + vec!["acme.root@1.0.0".to_owned()], + vec![locked_package( + "acme.root", + "1.0.0", + &digest, + BTreeMap::new(), + )], + vec![], + ); + + let error = build_context_graph(&lock, &cache).unwrap_err(); + assert!(matches!( + error, + ContextGraphError::InvalidResourceField { .. } + )); +} + +fn assert_resolution( + report: &commandf_pkg::ContextGraphReport, + relation: CanonicalReferenceRelation, + canonical: &str, + status: CanonicalResolutionStatus, + candidates: usize, +) { + let edge = report + .canonical_reference_edges + .iter() + .find(|edge| edge.relation == relation && edge.canonical == canonical) + .unwrap(); + assert_eq!(edge.resolution, status); + assert_eq!(edge.candidates.len(), candidates); +} + +fn locked_package( + name: &str, + version: &str, + sha256: &str, + dependencies: BTreeMap, +) -> LockedPackage { + LockedPackage { + name: name.to_owned(), + version: version.to_owned(), + sha256: sha256.to_owned(), + source: "memory:test".to_owned(), + dependencies, + } +} + +fn canonical_resource( + resource_type: &'static str, + id: &'static str, + url: &'static str, + version: &'static str, +) -> (&'static str, &'static [u8]) { + let filename = Box::leak(format!("package/{resource_type}-{id}.json").into_boxed_str()); + let body = Box::leak( + format!( + "{{\"resourceType\":\"{resource_type}\",\"id\":\"{id}\",\"url\":\"{url}\",\"version\":\"{version}\"}}" + ) + .into_bytes() + .into_boxed_slice(), + ); + (filename, body) +} + +fn package_archive(name: &str, version: &str, resources: &[(&str, &[u8])]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut builder = Builder::new(&mut encoder); + let manifest = format!("{{\"name\":\"{name}\",\"version\":\"{version}\"}}"); + append_entry(&mut builder, "package/package.json", manifest.as_bytes()); + for (path, body) in resources { + append_entry(&mut builder, path, body); + } + builder.finish().unwrap(); + } + encoder.finish().unwrap() +} + +fn append_entry(builder: &mut Builder<&mut GzEncoder>>, path: &str, body: &[u8]) { + let mut header = Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, Cursor::new(body)).unwrap(); +} From a5fb32ed0fe7445e539fa8b1094f0ff650f771f5 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 13:12:30 +0300 Subject: [PATCH 08/11] style(context): apply rustfmt to graph extraction --- crates/commandf-pkg/src/context.rs | 77 ++++++++++++++---------------- 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/crates/commandf-pkg/src/context.rs b/crates/commandf-pkg/src/context.rs index 5d8af6db..258f4555 100644 --- a/crates/commandf-pkg/src/context.rs +++ b/crates/commandf-pkg/src/context.rs @@ -52,17 +52,21 @@ pub fn build_context_graph( let from = package_identities .get(&(edge.from_name.clone(), edge.from_version.clone())) .cloned() - .ok_or_else(|| crate::PackageError::InvalidLockfile(format!( - "context graph source package {}@{} disappeared after lock validation", - edge.from_name, edge.from_version - )))?; + .ok_or_else(|| { + crate::PackageError::InvalidLockfile(format!( + "context graph source package {}@{} disappeared after lock validation", + edge.from_name, edge.from_version + )) + })?; let to = package_identities .get(&(edge.to_name.clone(), edge.to_version.clone())) .cloned() - .ok_or_else(|| crate::PackageError::InvalidLockfile(format!( - "context graph target package {}@{} disappeared after lock validation", - edge.to_name, edge.to_version - )))?; + .ok_or_else(|| { + crate::PackageError::InvalidLockfile(format!( + "context graph target package {}@{} disappeared after lock validation", + edge.to_name, edge.to_version + )) + })?; package_dependency_edges.push(ContextPackageDependencyEdge { from, to, @@ -102,10 +106,12 @@ pub fn build_context_graph( let package_identity = package_identities .get(&(package.name.clone(), package.version.clone())) .cloned() - .ok_or_else(|| crate::PackageError::InvalidLockfile(format!( - "context graph package {}@{} disappeared after lock validation", - package.name, package.version - )))?; + .ok_or_else(|| { + crate::PackageError::InvalidLockfile(format!( + "context graph package {}@{} disappeared after lock validation", + package.name, package.version + )) + })?; let artifact_identity = ContextArtifactIdentity { package: package_identity, filename: inspected.filename.clone(), @@ -125,11 +131,9 @@ pub fn build_context_graph( source, }) })?; - let object = value.as_object().ok_or_else(|| invalid_field( - &scanned.filename, - "$", - "a JSON object", - ))?; + let object = value + .as_object() + .ok_or_else(|| invalid_field(&scanned.filename, "$", "a JSON object"))?; extract_references( &artifact_identity, &scanned.filename, @@ -314,12 +318,13 @@ fn extract_structure_definition( let elements = required_array(differential, "element", file, "differential.element")?; for (element_index, element) in elements.iter().enumerate() { let element_path = format!("differential.element[{element_index}]"); - let element = element.as_object().ok_or_else(|| { - invalid_field(file, &element_path, "a JSON object") - })?; + let element = element + .as_object() + .ok_or_else(|| invalid_field(file, &element_path, "a JSON object"))?; let element_id = required_string(element, "id", file, &format!("{element_path}.id"))?; - if let Some(types) = optional_array(element, "type", file, &format!("{element_path}.type"))? { + if let Some(types) = optional_array(element, "type", file, &format!("{element_path}.type"))? + { for (type_index, type_value) in types.iter().enumerate() { let type_path = format!("{element_path}.type[{type_index}]"); let type_object = type_value @@ -348,12 +353,9 @@ fn extract_structure_definition( } } - if let Some(binding) = optional_object( - element, - "binding", - file, - &format!("{element_path}.binding"), - )? { + if let Some(binding) = + optional_object(element, "binding", file, &format!("{element_path}.binding"))? + { if let Some(canonical) = optional_string( binding, "valueSet", @@ -436,12 +438,9 @@ fn extract_value_set( let clause = clause .as_object() .ok_or_else(|| invalid_field(file, &clause_path, "a JSON object"))?; - if let Some(system) = optional_string( - clause, - "system", - file, - &format!("{clause_path}.system"), - )? { + if let Some(system) = + optional_string(clause, "system", file, &format!("{clause_path}.system"))? + { push_reference( output, source, @@ -528,8 +527,7 @@ fn required_string( file: &str, path: &str, ) -> Result { - optional_string(object, field, file, path)? - .ok_or_else(|| invalid_field(file, path, "a string")) + optional_string(object, field, file, path)?.ok_or_else(|| invalid_field(file, path, "a string")) } fn optional_array<'a>( @@ -551,8 +549,7 @@ fn required_array<'a>( file: &str, path: &str, ) -> Result<&'a Vec, ContextGraphError> { - optional_array(object, field, file, path)? - .ok_or_else(|| invalid_field(file, path, "an array")) + optional_array(object, field, file, path)?.ok_or_else(|| invalid_field(file, path, "an array")) } fn optional_object<'a>( @@ -568,11 +565,7 @@ fn optional_object<'a>( } } -fn invalid_field( - file: &str, - path: &str, - expected: &'static str, -) -> ContextGraphError { +fn invalid_field(file: &str, path: &str, expected: &'static str) -> ContextGraphError { ContextGraphError::InvalidResourceField { file: file.to_owned(), path: path.to_owned(), From 8de8c1638960366a1169fd94f9bb39ddb5a08e94 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 13:12:57 +0300 Subject: [PATCH 09/11] style(context): apply rustfmt to graph tests --- crates/commandf-pkg/tests/context_graph.rs | 24 +++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/crates/commandf-pkg/tests/context_graph.rs b/crates/commandf-pkg/tests/context_graph.rs index e860dd54..0e126919 100644 --- a/crates/commandf-pkg/tests/context_graph.rs +++ b/crates/commandf-pkg/tests/context_graph.rs @@ -134,18 +134,8 @@ fn builds_deterministic_context_graph_with_explicit_resolution_states() { let lock = Lockfile::new_v2( vec!["acme.refs@1.0.0".to_owned()], vec![ - locked_package( - "acme.refs", - "1.0.0", - &refs_sha, - refs_dependencies, - ), - locked_package( - "acme.targets", - "1.0.0", - &targets_sha, - BTreeMap::new(), - ), + locked_package("acme.refs", "1.0.0", &refs_sha, refs_dependencies), + locked_package("acme.targets", "1.0.0", &targets_sha, BTreeMap::new()), ], vec![ResolvedDependency { from_name: "acme.refs".to_owned(), @@ -158,12 +148,18 @@ fn builds_deterministic_context_graph_with_explicit_resolution_states() { let first = build_context_graph(&lock, &cache).unwrap(); let second = build_context_graph(&lock, &cache).unwrap(); - assert_eq!(first.to_json_bytes().unwrap(), second.to_json_bytes().unwrap()); + assert_eq!( + first.to_json_bytes().unwrap(), + second.to_json_bytes().unwrap() + ); assert_eq!(first.schema, 1); assert_eq!(first.lock_schema, Lockfile::SCHEMA_V2); assert_eq!(first.packages.len(), 2); assert_eq!(first.package_dependency_edges.len(), 1); - assert_eq!(first.coverage.unsupported_source_resource_types, vec!["Patient"]); + assert_eq!( + first.coverage.unsupported_source_resource_types, + vec!["Patient"] + ); assert_resolution( &first, From e3b89c59c45e7ba9890746cc681901431226123b Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 14:41:50 +0300 Subject: [PATCH 10/11] fix(pkg): propagate v1 resolved-edge refusal --- crates/commandf-pkg/src/lock.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/commandf-pkg/src/lock.rs b/crates/commandf-pkg/src/lock.rs index 0e0b4631..9c56e679 100644 --- a/crates/commandf-pkg/src/lock.rs +++ b/crates/commandf-pkg/src/lock.rs @@ -87,11 +87,18 @@ impl Lockfile { pub fn to_bytes(&self) -> Result, PackageError> { let mut bytes = match self.schema { - Self::SCHEMA_V1 => serde_json::to_vec_pretty(&LockfileV1 { - schema: self.schema, - roots: &self.roots, - packages: &self.packages, - })?, + Self::SCHEMA_V1 => { + if !self.resolved_dependencies.is_empty() { + return Err(PackageError::InvalidLockfile( + "schema v1 must not contain resolved_dependencies".to_owned(), + )); + } + serde_json::to_vec_pretty(&LockfileV1 { + schema: self.schema, + roots: &self.roots, + packages: &self.packages, + })? + } Self::SCHEMA_V2 => { self.validate_v2()?; serde_json::to_vec_pretty(&LockfileV2 { From 1dce479f7e2b548109e8d2b99a928353639be4b6 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Tue, 25 Aug 2026 14:42:15 +0300 Subject: [PATCH 11/11] test(pkg): propagate v1 resolved-edge refusal coverage --- crates/commandf-pkg/tests/lock_schema.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/commandf-pkg/tests/lock_schema.rs b/crates/commandf-pkg/tests/lock_schema.rs index 776b0b43..ff29cb17 100644 --- a/crates/commandf-pkg/tests/lock_schema.rs +++ b/crates/commandf-pkg/tests/lock_schema.rs @@ -22,6 +22,21 @@ fn schema_v1_rejects_resolved_dependency_evidence() { assert!(matches!(error, PackageError::InvalidLockfile(_))); } +#[test] +fn schema_v1_refuses_to_serialize_resolved_dependency_evidence() { + let mut lock = Lockfile::new(Vec::new(), Vec::new()); + lock.resolved_dependencies.push(ResolvedDependency { + from_name: "acme.parent".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.child".to_owned(), + to_version: "1.0.0".to_owned(), + declared_constraint: "1.0.0".to_owned(), + }); + + let error = lock.to_bytes().unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + #[test] fn schema_v2_requires_resolved_dependency_field() { let bytes = br#"{"schema":2,"roots":[],"packages":[]}"#;