From 12925c833fe532771bf58c491a0691708b61f3c1 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:10:10 +0300 Subject: [PATCH 1/9] feat(impact): add deterministic impact report model --- crates/commandf-pkg/src/impact_model.rs | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 crates/commandf-pkg/src/impact_model.rs diff --git a/crates/commandf-pkg/src/impact_model.rs b/crates/commandf-pkg/src/impact_model.rs new file mode 100644 index 00000000..e41e950e --- /dev/null +++ b/crates/commandf-pkg/src/impact_model.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + CanonicalReferenceRelation, CanonicalResolutionStatus, ContextArtifactIdentity, + ContextCoverage, ContextPackageIdentity, ContextPackageNode, +}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImpactSide { + Before, + After, + Both, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImpactSeedKind { + Added, + Removed, + Modified, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ImpactSeed { + pub kind: ImpactSeedKind, + pub canonical: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ImpactArtifactPathStep { + pub source: ContextArtifactIdentity, + pub target: ContextArtifactIdentity, + pub relation: CanonicalReferenceRelation, + pub source_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_element_id: Option, + pub canonical: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ImpactArtifactRelation { + pub impacted: ContextArtifactIdentity, + pub seed: ContextArtifactIdentity, + pub side: ImpactSide, + pub path: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ImpactPackagePathStep { + pub source: ContextPackageIdentity, + pub target: ContextPackageIdentity, + pub declared_constraint: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ImpactPackageRelation { + pub impacted: ContextPackageIdentity, + pub subject: ContextPackageIdentity, + pub side: ImpactSide, + pub path: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ImpactUnresolvedBoundary { + pub source: ContextArtifactIdentity, + pub seed: ContextArtifactIdentity, + pub side: ImpactSide, + pub relation: CanonicalReferenceRelation, + pub source_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_element_id: Option, + pub canonical: String, + pub resolution: CanonicalResolutionStatus, + pub candidates: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ImpactGraphEvidence { + pub graph_schema: u32, + pub lock_schema: u32, + pub root_requests: Vec, + pub subject: ContextPackageNode, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ImpactCoverage { + pub before: ContextCoverage, + pub after: ContextCoverage, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ImpactSubject { + pub package_name: String, + pub before: ContextPackageIdentity, + pub after: ContextPackageIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ImpactReport { + pub schema: u32, + pub subject: ImpactSubject, + pub before_evidence: ImpactGraphEvidence, + pub after_evidence: ImpactGraphEvidence, + pub seeds: Vec, + pub artifact_impacts: Vec, + pub package_impacts: Vec, + pub unresolved_boundaries: Vec, + pub coverage: ImpactCoverage, +} + +impl ImpactReport { + 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 f3ee1665fe726c80c6db9dea658b0b4c269a7e97 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:10:20 +0300 Subject: [PATCH 2/9] feat(impact): add fail-closed impact errors --- crates/commandf-pkg/src/impact_error.rs | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 crates/commandf-pkg/src/impact_error.rs diff --git a/crates/commandf-pkg/src/impact_error.rs b/crates/commandf-pkg/src/impact_error.rs new file mode 100644 index 00000000..8aa19ab8 --- /dev/null +++ b/crates/commandf-pkg/src/impact_error.rs @@ -0,0 +1,56 @@ +use std::fmt; + +#[derive(Debug, Eq, PartialEq)] +pub enum ImpactError { + UnsupportedDiffSchema { found: u32 }, + UnsupportedContextSchema { side: &'static str, found: u32 }, + UnsupportedLockSchema { side: &'static str, found: u32 }, + SubjectPackageMissing { side: &'static str, identity: String }, + SubjectPackageAmbiguous { side: &'static str, identity: String }, + ArtifactMissing { side: &'static str, file: String }, + ArtifactAmbiguous { side: &'static str, file: String }, + ConflictingResourceFilename { resource: String, side: &'static str }, + InconsistentResolvedReference { side: &'static str, canonical: String, candidates: usize }, +} + +impl fmt::Display for ImpactError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedDiffSchema { found } => { + write!(f, "impact requires structural diff schema 1, found {found}") + } + Self::UnsupportedContextSchema { side, found } => { + write!(f, "impact requires context graph schema 1 on {side}, found {found}") + } + Self::UnsupportedLockSchema { side, found } => { + write!(f, "impact requires lock schema 2 context evidence on {side}, found {found}") + } + Self::SubjectPackageMissing { side, identity } => { + write!(f, "impact subject package is missing from {side} context graph: {identity}") + } + Self::SubjectPackageAmbiguous { side, identity } => { + write!(f, "impact subject package is duplicated in {side} context graph: {identity}") + } + Self::ArtifactMissing { side, file } => { + write!(f, "impact diff artifact is missing from {side} context graph: {file}") + } + Self::ArtifactAmbiguous { side, file } => { + write!(f, "impact diff artifact is duplicated in {side} context graph: {file}") + } + Self::ConflictingResourceFilename { resource, side } => write!( + f, + "impact diff contains conflicting {side} filenames for resource {resource}" + ), + Self::InconsistentResolvedReference { + side, + canonical, + candidates, + } => write!( + f, + "resolved canonical reference on {side} must have exactly one candidate: {canonical} has {candidates}" + ), + } + } +} + +impl std::error::Error for ImpactError {} From c35e345af8825e5a4afbea04d46b724bb22e6474 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:11:20 +0300 Subject: [PATCH 3/9] feat(impact): implement deterministic blast-radius traversal --- crates/commandf-pkg/src/impact.rs | 628 ++++++++++++++++++++++++++++++ 1 file changed, 628 insertions(+) create mode 100644 crates/commandf-pkg/src/impact.rs diff --git a/crates/commandf-pkg/src/impact.rs b/crates/commandf-pkg/src/impact.rs new file mode 100644 index 00000000..cd189021 --- /dev/null +++ b/crates/commandf-pkg/src/impact.rs @@ -0,0 +1,628 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{ + CanonicalResolutionStatus, ContextArtifactIdentity, ContextArtifactNode, + ContextCanonicalReferenceEdge, ContextGraphReport, ContextPackageDependencyEdge, + ContextPackageIdentity, ContextPackageNode, ImpactArtifactPathStep, ImpactArtifactRelation, + ImpactCoverage, ImpactError, ImpactGraphEvidence, ImpactPackagePathStep, ImpactPackageRelation, + ImpactReport, ImpactSeed, ImpactSeedKind, ImpactSide, ImpactSubject, + ImpactUnresolvedBoundary, Lockfile, ResourceKey, ResourceKeyKind, StructuralChangeKind, + StructuralDiffReport, +}; + +#[derive(Default)] +struct SeedAccumulator { + before_filename: Option, + after_filename: Option, + added: bool, + removed: bool, + modified: bool, +} + +pub fn build_impact_report( + diff: &StructuralDiffReport, + before_graph: &ContextGraphReport, + after_graph: &ContextGraphReport, +) -> Result { + validate_inputs(diff, before_graph, after_graph)?; + + let before_subject = subject_node(before_graph, diff, ImpactSide::Before)?; + let after_subject = subject_node(after_graph, diff, ImpactSide::After)?; + let seeds = build_seeds( + diff, + before_graph, + after_graph, + &before_subject, + &after_subject, + )?; + + let mut artifact_impacts = Vec::new(); + let mut unresolved_boundaries = Vec::new(); + for seed in &seeds { + if let Some(before_seed) = &seed.before { + collect_artifact_side( + before_graph, + before_seed, + ImpactSide::Before, + &mut artifact_impacts, + &mut unresolved_boundaries, + )?; + } + if let Some(after_seed) = &seed.after { + collect_artifact_side( + after_graph, + after_seed, + ImpactSide::After, + &mut artifact_impacts, + &mut unresolved_boundaries, + )?; + } + } + + let mut package_impacts = Vec::new(); + collect_package_side( + before_graph, + &before_subject.identity, + ImpactSide::Before, + &mut package_impacts, + ); + collect_package_side( + after_graph, + &after_subject.identity, + ImpactSide::After, + &mut package_impacts, + ); + + let artifact_impacts = normalize_artifact_sides(artifact_impacts); + let package_impacts = normalize_package_sides(package_impacts); + let unresolved_boundaries = normalize_boundary_sides(unresolved_boundaries); + + Ok(ImpactReport { + schema: ImpactReport::SCHEMA_V1, + subject: ImpactSubject { + package_name: diff.package_name.clone(), + before: before_subject.identity.clone(), + after: after_subject.identity.clone(), + }, + before_evidence: graph_evidence(before_graph, before_subject), + after_evidence: graph_evidence(after_graph, after_subject), + seeds, + artifact_impacts, + package_impacts, + unresolved_boundaries, + coverage: ImpactCoverage { + before: before_graph.coverage.clone(), + after: after_graph.coverage.clone(), + }, + }) +} + +fn validate_inputs( + diff: &StructuralDiffReport, + before_graph: &ContextGraphReport, + after_graph: &ContextGraphReport, +) -> Result<(), ImpactError> { + if diff.schema != StructuralDiffReport::SCHEMA_V1 { + return Err(ImpactError::UnsupportedDiffSchema { found: diff.schema }); + } + for (side, graph) in [("before", before_graph), ("after", after_graph)] { + if graph.schema != ContextGraphReport::SCHEMA_V1 { + return Err(ImpactError::UnsupportedContextSchema { + side, + found: graph.schema, + }); + } + if graph.lock_schema != Lockfile::SCHEMA_V2 { + return Err(ImpactError::UnsupportedLockSchema { + side, + found: graph.lock_schema, + }); + } + } + Ok(()) +} + +fn subject_node( + graph: &ContextGraphReport, + diff: &StructuralDiffReport, + side: ImpactSide, +) -> Result { + let (version, sha256, label) = match side { + ImpactSide::Before => ( + diff.before.version.as_str(), + diff.before.archive_sha256.as_str(), + "before", + ), + ImpactSide::After => ( + diff.after.version.as_str(), + diff.after.archive_sha256.as_str(), + "after", + ), + ImpactSide::Both => unreachable!("subject lookup is side-specific"), + }; + let expected = ContextPackageIdentity { + name: diff.package_name.clone(), + version: version.to_owned(), + sha256: sha256.to_owned(), + }; + let matches = graph + .packages + .iter() + .filter(|package| package.identity == expected) + .collect::>(); + match matches.as_slice() { + [package] => Ok((*package).clone()), + [] => Err(ImpactError::SubjectPackageMissing { + side: label, + identity: format!("{}@{}#{}", expected.name, expected.version, expected.sha256), + }), + _ => Err(ImpactError::SubjectPackageAmbiguous { + side: label, + identity: format!("{}@{}#{}", expected.name, expected.version, expected.sha256), + }), + } +} + +fn graph_evidence(graph: &ContextGraphReport, subject: ContextPackageNode) -> ImpactGraphEvidence { + ImpactGraphEvidence { + graph_schema: graph.schema, + lock_schema: graph.lock_schema, + root_requests: graph.root_requests.clone(), + subject, + } +} + +fn build_seeds( + diff: &StructuralDiffReport, + before_graph: &ContextGraphReport, + after_graph: &ContextGraphReport, + before_subject: &ContextPackageNode, + after_subject: &ContextPackageNode, +) -> Result, ImpactError> { + let mut grouped = BTreeMap::::new(); + for change in &diff.changes { + let entry = grouped.entry(change.resource.clone()).or_default(); + merge_filename( + &mut entry.before_filename, + change.before_filename.as_deref(), + &change.resource, + "before", + )?; + merge_filename( + &mut entry.after_filename, + change.after_filename.as_deref(), + &change.resource, + "after", + )?; + match change.kind { + StructuralChangeKind::ResourceAdded => entry.added = true, + StructuralChangeKind::ResourceRemoved => entry.removed = true, + _ => entry.modified = true, + } + } + + let mut seeds = Vec::new(); + for (resource, state) in grouped { + let before = artifact_for_filename( + before_graph, + &before_subject.identity, + state.before_filename.as_deref(), + "before", + )?; + let after = artifact_for_filename( + after_graph, + &after_subject.identity, + state.after_filename.as_deref(), + "after", + )?; + let before_canonical = before.as_ref().and_then(|node| node.canonical_url.clone()); + let after_canonical = after.as_ref().and_then(|node| node.canonical_url.clone()); + + match (before_canonical, after_canonical) { + (None, None) => { + if resource.kind == ResourceKeyKind::Canonical { + push_seed_from_key(&mut seeds, &resource, &state, before, after); + } + } + (Some(before_url), Some(after_url)) if before_url == after_url => { + seeds.push(ImpactSeed { + kind: seed_kind(&state), + canonical: before_url, + before: before.map(|node| node.identity), + after: after.map(|node| node.identity), + }); + } + (Some(before_url), Some(after_url)) => { + seeds.push(ImpactSeed { + kind: ImpactSeedKind::Removed, + canonical: before_url, + before: before.map(|node| node.identity), + after: None, + }); + seeds.push(ImpactSeed { + kind: ImpactSeedKind::Added, + canonical: after_url, + before: None, + after: after.map(|node| node.identity), + }); + } + (Some(before_url), None) => seeds.push(ImpactSeed { + kind: ImpactSeedKind::Removed, + canonical: before_url, + before: before.map(|node| node.identity), + after: None, + }), + (None, Some(after_url)) => seeds.push(ImpactSeed { + kind: ImpactSeedKind::Added, + canonical: after_url, + before: None, + after: after.map(|node| node.identity), + }), + } + } + seeds.sort(); + seeds.dedup(); + Ok(seeds) +} + +fn push_seed_from_key( + seeds: &mut Vec, + resource: &ResourceKey, + state: &SeedAccumulator, + before: Option, + after: Option, +) { + seeds.push(ImpactSeed { + kind: seed_kind(state), + canonical: resource.value.clone(), + before: before.map(|node| node.identity), + after: after.map(|node| node.identity), + }); +} + +fn seed_kind(state: &SeedAccumulator) -> ImpactSeedKind { + if state.added && !state.removed && !state.modified { + ImpactSeedKind::Added + } else if state.removed && !state.added && !state.modified { + ImpactSeedKind::Removed + } else { + ImpactSeedKind::Modified + } +} + +fn merge_filename( + slot: &mut Option, + candidate: Option<&str>, + resource: &ResourceKey, + side: &'static str, +) -> Result<(), ImpactError> { + let Some(candidate) = candidate else { + return Ok(()); + }; + match slot { + Some(existing) if existing != candidate => Err(ImpactError::ConflictingResourceFilename { + resource: format!("{:?}:{}", resource.kind, resource.value), + side, + }), + Some(_) => Ok(()), + None => { + *slot = Some(candidate.to_owned()); + Ok(()) + } + } +} + +fn artifact_for_filename( + graph: &ContextGraphReport, + package: &ContextPackageIdentity, + filename: Option<&str>, + side: &'static str, +) -> Result, ImpactError> { + let Some(filename) = filename else { + return Ok(None); + }; + let matches = graph + .artifacts + .iter() + .filter(|artifact| artifact.identity.package == *package && artifact.identity.filename == filename) + .collect::>(); + match matches.as_slice() { + [artifact] => Ok(Some((*artifact).clone())), + [] => Err(ImpactError::ArtifactMissing { + side, + file: filename.to_owned(), + }), + _ => Err(ImpactError::ArtifactAmbiguous { + side, + file: filename.to_owned(), + }), + } +} + +fn collect_artifact_side( + graph: &ContextGraphReport, + seed: &ContextArtifactIdentity, + side: ImpactSide, + impacts: &mut Vec, + boundaries: &mut Vec, +) -> Result<(), ImpactError> { + let best = artifact_paths(graph, seed, side)?; + let mut sources = BTreeSet::from([seed.clone()]); + for (impacted, path) in &best { + sources.insert(impacted.clone()); + impacts.push(ImpactArtifactRelation { + impacted: impacted.clone(), + seed: seed.clone(), + side, + path: path.clone(), + }); + } + + for edge in &graph.canonical_reference_edges { + if !sources.contains(&edge.source) || edge.resolution == CanonicalResolutionStatus::Resolved { + continue; + } + boundaries.push(ImpactUnresolvedBoundary { + source: edge.source.clone(), + seed: seed.clone(), + side, + relation: edge.relation, + source_path: edge.source_path.clone(), + source_element_id: edge.source_element_id.clone(), + canonical: edge.canonical.clone(), + resolution: edge.resolution, + candidates: edge.candidates.clone(), + }); + } + Ok(()) +} + +fn artifact_paths( + graph: &ContextGraphReport, + seed: &ContextArtifactIdentity, + side: ImpactSide, +) -> Result>, ImpactError> { + let label = side_label(side); + let mut reverse = BTreeMap::>::new(); + for edge in &graph.canonical_reference_edges { + if edge.resolution != CanonicalResolutionStatus::Resolved { + continue; + } + if edge.candidates.len() != 1 { + return Err(ImpactError::InconsistentResolvedReference { + side: label, + canonical: edge.canonical.clone(), + candidates: edge.candidates.len(), + }); + } + reverse + .entry(edge.candidates[0].clone()) + .or_default() + .push(edge.clone()); + } + for edges in reverse.values_mut() { + edges.sort(); + edges.dedup(); + } + + let mut best = BTreeMap::>::new(); + best.insert(seed.clone(), Vec::new()); + let mut frontier = BTreeSet::new(); + frontier.insert((0usize, Vec::::new(), seed.clone())); + + while let Some((_, path, current)) = frontier.pop_first() { + if best.get(¤t) != Some(&path) { + continue; + } + for edge in reverse.get(¤t).into_iter().flatten() { + let step = ImpactArtifactPathStep { + source: edge.source.clone(), + target: current.clone(), + relation: edge.relation, + source_path: edge.source_path.clone(), + source_element_id: edge.source_element_id.clone(), + canonical: edge.canonical.clone(), + }; + let mut candidate = Vec::with_capacity(path.len() + 1); + candidate.push(step); + candidate.extend(path.iter().cloned()); + let source = edge.source.clone(); + if is_better_path(best.get(&source), &candidate) { + best.insert(source.clone(), candidate.clone()); + frontier.insert((candidate.len(), candidate, source)); + } + } + } + best.remove(seed); + Ok(best) +} + +fn collect_package_side( + graph: &ContextGraphReport, + subject: &ContextPackageIdentity, + side: ImpactSide, + impacts: &mut Vec, +) { + for (impacted, path) in package_paths(graph, subject) { + impacts.push(ImpactPackageRelation { + impacted, + subject: subject.clone(), + side, + path, + }); + } +} + +fn package_paths( + graph: &ContextGraphReport, + subject: &ContextPackageIdentity, +) -> BTreeMap> { + let mut reverse = BTreeMap::>::new(); + for edge in &graph.package_dependency_edges { + reverse.entry(edge.to.clone()).or_default().push(edge.clone()); + } + for edges in reverse.values_mut() { + edges.sort(); + edges.dedup(); + } + + let mut best = BTreeMap::>::new(); + best.insert(subject.clone(), Vec::new()); + let mut frontier = BTreeSet::new(); + frontier.insert((0usize, Vec::::new(), subject.clone())); + + while let Some((_, path, current)) = frontier.pop_first() { + if best.get(¤t) != Some(&path) { + continue; + } + for edge in reverse.get(¤t).into_iter().flatten() { + let step = ImpactPackagePathStep { + source: edge.from.clone(), + target: current.clone(), + declared_constraint: edge.declared_constraint.clone(), + }; + let mut candidate = Vec::with_capacity(path.len() + 1); + candidate.push(step); + candidate.extend(path.iter().cloned()); + let source = edge.from.clone(); + if is_better_path(best.get(&source), &candidate) { + best.insert(source.clone(), candidate.clone()); + frontier.insert((candidate.len(), candidate, source)); + } + } + } + best.remove(subject); + best +} + +fn is_better_path(existing: Option<&Vec>, candidate: &Vec) -> bool { + match existing { + None => true, + Some(existing) => (candidate.len(), candidate) < (existing.len(), existing), + } +} + +fn normalize_artifact_sides(input: Vec) -> Vec { + let mut grouped = BTreeMap::< + (ContextArtifactIdentity, ContextArtifactIdentity, Vec), + BTreeSet, + >::new(); + for relation in input { + grouped + .entry((relation.impacted, relation.seed, relation.path)) + .or_default() + .insert(relation.side); + } + let mut output = grouped + .into_iter() + .map(|((impacted, seed, path), sides)| ImpactArtifactRelation { + impacted, + seed, + side: normalized_side(&sides), + path, + }) + .collect::>(); + output.sort(); + output +} + +fn normalize_package_sides(input: Vec) -> Vec { + let mut grouped = BTreeMap::< + (ContextPackageIdentity, ContextPackageIdentity, Vec), + BTreeSet, + >::new(); + for relation in input { + grouped + .entry((relation.impacted, relation.subject, relation.path)) + .or_default() + .insert(relation.side); + } + let mut output = grouped + .into_iter() + .map(|((impacted, subject, path), sides)| ImpactPackageRelation { + impacted, + subject, + side: normalized_side(&sides), + path, + }) + .collect::>(); + output.sort(); + output +} + +fn normalize_boundary_sides(input: Vec) -> Vec { + type BoundaryKey = ( + ContextArtifactIdentity, + ContextArtifactIdentity, + crate::CanonicalReferenceRelation, + String, + Option, + String, + CanonicalResolutionStatus, + Vec, + ); + let mut grouped = BTreeMap::>::new(); + for boundary in input { + grouped + .entry(( + boundary.source, + boundary.seed, + boundary.relation, + boundary.source_path, + boundary.source_element_id, + boundary.canonical, + boundary.resolution, + boundary.candidates, + )) + .or_default() + .insert(boundary.side); + } + let mut output = grouped + .into_iter() + .map( + |( + ( + source, + seed, + relation, + source_path, + source_element_id, + canonical, + resolution, + candidates, + ), + sides, + )| ImpactUnresolvedBoundary { + source, + seed, + side: normalized_side(&sides), + relation, + source_path, + source_element_id, + canonical, + resolution, + candidates, + }, + ) + .collect::>(); + output.sort(); + output +} + +fn normalized_side(sides: &BTreeSet) -> ImpactSide { + if sides.contains(&ImpactSide::Before) && sides.contains(&ImpactSide::After) { + ImpactSide::Both + } else if sides.contains(&ImpactSide::Before) { + ImpactSide::Before + } else { + ImpactSide::After + } +} + +fn side_label(side: ImpactSide) -> &'static str { + match side { + ImpactSide::Before => "before", + ImpactSide::After => "after", + ImpactSide::Both => "both", + } +} From c8ca4060d5c6dcd7b60642f1ac9275ced7b2ada1 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:11:34 +0300 Subject: [PATCH 4/9] feat(impact): export impact library surface --- 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 3c1a1b91..ed0c0105 100644 --- a/crates/commandf-pkg/src/lib.rs +++ b/crates/commandf-pkg/src/lib.rs @@ -23,6 +23,9 @@ mod context; mod context_error; mod context_model; mod error; +mod impact; +mod impact_error; +mod impact_model; mod lock; mod model; mod oracle_error; @@ -74,6 +77,13 @@ pub use context_model::{ ContextPackageDependencyEdge, ContextPackageIdentity, ContextPackageNode, }; pub use error::PackageError; +pub use impact::build_impact_report; +pub use impact_error::ImpactError; +pub use impact_model::{ + ImpactArtifactPathStep, ImpactArtifactRelation, ImpactCoverage, ImpactGraphEvidence, + ImpactPackagePathStep, ImpactPackageRelation, ImpactReport, ImpactSeed, ImpactSeedKind, + ImpactSide, ImpactSubject, ImpactUnresolvedBoundary, +}; pub use lock::{LockedPackage, Lockfile, ResolvedDependency}; pub use model::{PackageName, PackageRequest, VersionConstraint}; pub use oracle_error::OracleError; From 643db9d9d9eba2405556273f1840dbae7d9462aa Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:12:21 +0300 Subject: [PATCH 5/9] test(impact): prove deterministic blast-radius invariants --- crates/commandf-pkg/tests/impact.rs | 423 ++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 crates/commandf-pkg/tests/impact.rs diff --git a/crates/commandf-pkg/tests/impact.rs b/crates/commandf-pkg/tests/impact.rs new file mode 100644 index 00000000..d760a01f --- /dev/null +++ b/crates/commandf-pkg/tests/impact.rs @@ -0,0 +1,423 @@ +use commandf_pkg::{ + build_impact_report, CanonicalReferenceRelation, CanonicalResolutionStatus, + ContextArtifactIdentity, ContextArtifactNode, ContextCanonicalReferenceEdge, ContextCoverage, + ContextGraphReport, ContextPackageDependencyEdge, ContextPackageIdentity, ContextPackageNode, + ImpactSeedKind, ImpactSide, Lockfile, PackageEvidence, ResourceKey, ResourceKeyKind, + StructuralChange, StructuralChangeKind, StructuralDiffReport, +}; + +#[test] +fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { + let before_subject = package("acme.changed", "1.0.0", "subject-before"); + let after_subject = package("acme.changed", "2.0.0", "subject-after"); + let dependent = package("acme.dep", "1.0.0", "dep"); + + let before_seed = artifact(&before_subject, "seed.json", "seed-before", Some("https://example.org/B")); + let after_seed = artifact(&after_subject, "seed.json", "seed-after", Some("https://example.org/B")); + let a = artifact(&dependent, "a.json", "a", Some("https://example.org/A")); + let x = artifact(&dependent, "x.json", "x", Some("https://example.org/X")); + + let before = graph( + vec![before_subject.clone(), dependent.clone()], + vec![before_seed.clone(), a.clone(), x.clone()], + vec![dependency(&dependent, &before_subject, "1.0.0")], + vec![ + resolved(&a, &before_seed, "https://example.org/B"), + resolved(&x, &a, "https://example.org/A"), + resolved(&a, &x, "https://example.org/X"), + external(&a, "https://external.example/ValueSet/missing"), + ambiguous(&x, "https://example.org/ambiguous", vec![a.identity.clone(), before_seed.identity.clone()]), + ], + ); + let after = graph( + vec![after_subject.clone(), dependent.clone()], + vec![after_seed.clone(), a.clone(), x.clone()], + vec![dependency(&dependent, &after_subject, "2.0.0")], + vec![ + resolved(&a, &after_seed, "https://example.org/B"), + resolved(&x, &a, "https://example.org/A"), + resolved(&a, &x, "https://example.org/X"), + external(&a, "https://external.example/ValueSet/missing"), + ambiguous(&x, "https://example.org/ambiguous", vec![a.identity.clone(), after_seed.identity.clone()]), + ], + ); + let diff = modified_diff(&before_subject, &after_subject, "https://example.org/B", "seed.json"); + + let report = build_impact_report(&diff, &before, &after).unwrap(); + + assert_eq!(report.seeds.len(), 1); + assert_eq!(report.seeds[0].kind, ImpactSeedKind::Modified); + assert_eq!(report.artifact_impacts.len(), 4); + assert!(report.artifact_impacts.iter().any(|impact| { + impact.impacted == a.identity && impact.side == ImpactSide::Before && impact.path.len() == 1 + })); + assert!(report.artifact_impacts.iter().any(|impact| { + impact.impacted == x.identity && impact.side == ImpactSide::After && impact.path.len() == 2 + })); + assert_eq!(report.package_impacts.len(), 2); + assert_eq!(report.unresolved_boundaries.len(), 4); + assert!(report + .unresolved_boundaries + .iter() + .all(|boundary| boundary.resolution != CanonicalResolutionStatus::Resolved)); + + let first = report.to_json_bytes().unwrap(); + let second = build_impact_report(&diff, &before, &after) + .unwrap() + .to_json_bytes() + .unwrap(); + assert_eq!(first, second); +} + +#[test] +fn chooses_lexicographically_first_equal_length_shortest_path() { + let subject = package("acme.changed", "1.0.0", "subject"); + let dep = package("acme.dep", "1.0.0", "dep"); + let seed = artifact(&subject, "seed.json", "seed", Some("https://example.org/Seed")); + let a = artifact(&dep, "a.json", "a", Some("https://example.org/A")); + let c = artifact(&dep, "c.json", "c", Some("https://example.org/C")); + let d = artifact(&dep, "d.json", "d", Some("https://example.org/D")); + let graph = graph( + vec![subject.clone(), dep], + vec![seed.clone(), a.clone(), c.clone(), d.clone()], + Vec::new(), + vec![ + resolved(&a, &seed, "https://example.org/Seed"), + resolved(&c, &seed, "https://example.org/Seed"), + resolved(&d, &a, "https://example.org/A"), + resolved(&d, &c, "https://example.org/C"), + ], + ); + let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); + + let report = build_impact_report(&diff, &graph, &graph).unwrap(); + let d_impact = report + .artifact_impacts + .iter() + .find(|impact| impact.impacted == d.identity) + .unwrap(); + + assert_eq!(d_impact.side, ImpactSide::Both); + assert_eq!(d_impact.path.len(), 2); + assert_eq!(d_impact.path[0].target, a.identity); +} + +#[test] +fn preserves_added_and_removed_canonical_seeds_on_their_evidence_side() { + let before_subject = package("acme.changed", "1.0.0", "before"); + let after_subject = package("acme.changed", "2.0.0", "after"); + let removed = artifact(&before_subject, "removed.json", "removed", Some("https://example.org/Removed")); + let added = artifact(&after_subject, "added.json", "added", Some("https://example.org/Added")); + let before = graph(vec![before_subject.clone()], vec![removed.clone()], Vec::new(), Vec::new()); + let after = graph(vec![after_subject.clone()], vec![added.clone()], Vec::new(), Vec::new()); + let diff = StructuralDiffReport { + schema: StructuralDiffReport::SCHEMA_V1, + package_name: "acme.changed".to_owned(), + before: PackageEvidence { + version: before_subject.version.clone(), + archive_sha256: before_subject.sha256.clone(), + }, + after: PackageEvidence { + version: after_subject.version.clone(), + archive_sha256: after_subject.sha256.clone(), + }, + changes: vec![ + change( + StructuralChangeKind::ResourceRemoved, + "https://example.org/Removed", + Some("removed.json"), + None, + ), + change( + StructuralChangeKind::ResourceAdded, + "https://example.org/Added", + None, + Some("added.json"), + ), + ], + }; + + let report = build_impact_report(&diff, &before, &after).unwrap(); + assert_eq!(report.seeds.len(), 2); + let added_seed = report + .seeds + .iter() + .find(|seed| seed.kind == ImpactSeedKind::Added) + .unwrap(); + assert!(added_seed.before.is_none()); + assert_eq!(added_seed.after.as_ref(), Some(&added.identity)); + let removed_seed = report + .seeds + .iter() + .find(|seed| seed.kind == ImpactSeedKind::Removed) + .unwrap(); + assert_eq!(removed_seed.before.as_ref(), Some(&removed.identity)); + assert!(removed_seed.after.is_none()); +} + +#[test] +fn canonical_url_change_becomes_removed_and_added_seed() { + let before_subject = package("acme.changed", "1.0.0", "before"); + let after_subject = package("acme.changed", "2.0.0", "after"); + let before_artifact = artifact(&before_subject, "profile.json", "old", Some("https://example.org/Old")); + let after_artifact = artifact(&after_subject, "profile.json", "new", Some("https://example.org/New")); + let before = graph(vec![before_subject.clone()], vec![before_artifact], Vec::new(), Vec::new()); + let after = graph(vec![after_subject.clone()], vec![after_artifact], Vec::new(), Vec::new()); + let diff = StructuralDiffReport { + schema: 1, + package_name: "acme.changed".to_owned(), + before: PackageEvidence { + version: before_subject.version.clone(), + archive_sha256: before_subject.sha256.clone(), + }, + after: PackageEvidence { + version: after_subject.version.clone(), + archive_sha256: after_subject.sha256.clone(), + }, + changes: vec![StructuralChange { + kind: StructuralChangeKind::ResourceBytesChanged, + resource: ResourceKey { + kind: ResourceKeyKind::ResourceId, + value: "profile".to_owned(), + }, + before_filename: Some("profile.json".to_owned()), + after_filename: Some("profile.json".to_owned()), + view: None, + element_id: None, + field: None, + before: None, + after: None, + }], + }; + + let report = build_impact_report(&diff, &before, &after).unwrap(); + assert_eq!(report.seeds.len(), 2); + assert_eq!(report.seeds[0].kind, ImpactSeedKind::Added); + assert_eq!(report.seeds[0].canonical, "https://example.org/New"); + assert_eq!(report.seeds[1].kind, ImpactSeedKind::Removed); + assert_eq!(report.seeds[1].canonical, "https://example.org/Old"); +} + +#[test] +fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { + let subject = package("acme.changed", "2.0.0", "subject"); + let dep_v1 = package("acme.dep", "1.0.0", "dep-v1"); + let dep_v2 = package("acme.dep", "2.0.0", "dep-v2"); + let root = package("acme.root", "1.0.0", "root"); + let seed = artifact(&subject, "seed.json", "seed", Some("https://example.org/Seed")); + let graph = graph( + vec![subject.clone(), dep_v1.clone(), dep_v2.clone(), root.clone()], + vec![seed], + vec![ + dependency(&dep_v1, &subject, "2.0.0"), + dependency(&dep_v2, &subject, "2.0.0"), + dependency(&root, &dep_v1, "1.0.0"), + dependency(&root, &dep_v2, "2.0.0"), + dependency(&dep_v1, &root, "1.0.0"), + ], + Vec::new(), + ); + let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); + + let report = build_impact_report(&diff, &graph, &graph).unwrap(); + let identities = report + .package_impacts + .iter() + .map(|impact| impact.impacted.clone()) + .collect::>(); + assert!(identities.contains(&dep_v1)); + assert!(identities.contains(&dep_v2)); + assert!(identities.contains(&root)); + assert_eq!(identities.len(), 3); + assert!(report + .package_impacts + .iter() + .all(|impact| impact.side == ImpactSide::Both)); +} + +#[test] +fn does_not_traverse_ambiguous_or_external_reference_edges() { + let subject = package("acme.changed", "1.0.0", "subject"); + let dep = package("acme.dep", "1.0.0", "dep"); + let seed = artifact(&subject, "seed.json", "seed", Some("https://example.org/Seed")); + let source = artifact(&dep, "source.json", "source", Some("https://example.org/Source")); + let graph = graph( + vec![subject.clone(), dep], + vec![seed.clone(), source.clone()], + Vec::new(), + vec![ContextCanonicalReferenceEdge { + source: source.identity.clone(), + relation: CanonicalReferenceRelation::StructureBaseDefinition, + source_path: "baseDefinition".to_owned(), + source_element_id: None, + canonical: "https://example.org/Seed".to_owned(), + resolution: CanonicalResolutionStatus::Ambiguous, + candidates: vec![seed.identity.clone(), source.identity.clone()], + }], + ); + let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); + + let report = build_impact_report(&diff, &graph, &graph).unwrap(); + assert!(report.artifact_impacts.is_empty()); + assert!(report.unresolved_boundaries.is_empty()); +} + +fn modified_diff( + before: &ContextPackageIdentity, + after: &ContextPackageIdentity, + canonical: &str, + filename: &str, +) -> StructuralDiffReport { + StructuralDiffReport { + schema: StructuralDiffReport::SCHEMA_V1, + package_name: before.name.clone(), + before: PackageEvidence { + version: before.version.clone(), + archive_sha256: before.sha256.clone(), + }, + after: PackageEvidence { + version: after.version.clone(), + archive_sha256: after.sha256.clone(), + }, + changes: vec![change( + StructuralChangeKind::ResourceBytesChanged, + canonical, + Some(filename), + Some(filename), + )], + } +} + +fn change( + kind: StructuralChangeKind, + canonical: &str, + before_filename: Option<&str>, + after_filename: Option<&str>, +) -> StructuralChange { + StructuralChange { + kind, + resource: ResourceKey { + kind: ResourceKeyKind::Canonical, + value: canonical.to_owned(), + }, + before_filename: before_filename.map(str::to_owned), + after_filename: after_filename.map(str::to_owned), + view: None, + element_id: None, + field: None, + before: None, + after: None, + } +} + +fn graph( + packages: Vec, + artifacts: Vec, + package_dependency_edges: Vec, + canonical_reference_edges: Vec, +) -> ContextGraphReport { + ContextGraphReport { + schema: ContextGraphReport::SCHEMA_V1, + lock_schema: Lockfile::SCHEMA_V2, + root_requests: Vec::new(), + packages: packages + .into_iter() + .map(|identity| ContextPackageNode { + identity, + source: "https://packages.example/archive.tgz".to_owned(), + }) + .collect(), + artifacts, + package_dependency_edges, + canonical_reference_edges, + coverage: ContextCoverage { + extractor_schema: 1, + supported_source_resource_types: vec!["StructureDefinition".to_owned()], + unsupported_source_resource_types: Vec::new(), + }, + } +} + +fn package(name: &str, version: &str, sha256: &str) -> ContextPackageIdentity { + ContextPackageIdentity { + name: name.to_owned(), + version: version.to_owned(), + sha256: sha256.to_owned(), + } +} + +fn artifact( + package: &ContextPackageIdentity, + filename: &str, + sha256: &str, + canonical_url: Option<&str>, +) -> ContextArtifactNode { + ContextArtifactNode { + identity: ContextArtifactIdentity { + package: package.clone(), + filename: filename.to_owned(), + sha256: sha256.to_owned(), + }, + resource_type: "StructureDefinition".to_owned(), + id: Some(filename.trim_end_matches(".json").to_owned()), + canonical_url: canonical_url.map(str::to_owned), + canonical_version: Some("1.0.0".to_owned()), + } +} + +fn dependency( + from: &ContextPackageIdentity, + to: &ContextPackageIdentity, + constraint: &str, +) -> ContextPackageDependencyEdge { + ContextPackageDependencyEdge { + from: from.clone(), + to: to.clone(), + declared_constraint: constraint.to_owned(), + } +} + +fn resolved( + source: &ContextArtifactNode, + target: &ContextArtifactNode, + canonical: &str, +) -> ContextCanonicalReferenceEdge { + ContextCanonicalReferenceEdge { + source: source.identity.clone(), + relation: CanonicalReferenceRelation::StructureBaseDefinition, + source_path: "baseDefinition".to_owned(), + source_element_id: None, + canonical: canonical.to_owned(), + resolution: CanonicalResolutionStatus::Resolved, + candidates: vec![target.identity.clone()], + } +} + +fn external(source: &ContextArtifactNode, canonical: &str) -> ContextCanonicalReferenceEdge { + ContextCanonicalReferenceEdge { + source: source.identity.clone(), + relation: CanonicalReferenceRelation::StructureBindingValueSet, + source_path: "differential.element[0].binding.valueSet".to_owned(), + source_element_id: Some("Observation.value".to_owned()), + canonical: canonical.to_owned(), + resolution: CanonicalResolutionStatus::External, + candidates: Vec::new(), + } +} + +fn ambiguous( + source: &ContextArtifactNode, + canonical: &str, + mut candidates: Vec, +) -> ContextCanonicalReferenceEdge { + candidates.sort(); + ContextCanonicalReferenceEdge { + source: source.identity.clone(), + relation: CanonicalReferenceRelation::StructureTypeProfile, + source_path: "differential.element[0].type[0].profile[0]".to_owned(), + source_element_id: Some("Observation.value".to_owned()), + canonical: canonical.to_owned(), + resolution: CanonicalResolutionStatus::Ambiguous, + candidates, + } +} From 818a6998e853b4ea1756dbf63bedf72768a73e60 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:13:46 +0300 Subject: [PATCH 6/9] style(impact): apply rustfmt --- crates/commandf-pkg/src/impact_error.rs | 45 ++++++++++++++++++++----- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/commandf-pkg/src/impact_error.rs b/crates/commandf-pkg/src/impact_error.rs index 8aa19ab8..ddf72b38 100644 --- a/crates/commandf-pkg/src/impact_error.rs +++ b/crates/commandf-pkg/src/impact_error.rs @@ -2,15 +2,42 @@ use std::fmt; #[derive(Debug, Eq, PartialEq)] pub enum ImpactError { - UnsupportedDiffSchema { found: u32 }, - UnsupportedContextSchema { side: &'static str, found: u32 }, - UnsupportedLockSchema { side: &'static str, found: u32 }, - SubjectPackageMissing { side: &'static str, identity: String }, - SubjectPackageAmbiguous { side: &'static str, identity: String }, - ArtifactMissing { side: &'static str, file: String }, - ArtifactAmbiguous { side: &'static str, file: String }, - ConflictingResourceFilename { resource: String, side: &'static str }, - InconsistentResolvedReference { side: &'static str, canonical: String, candidates: usize }, + UnsupportedDiffSchema { + found: u32, + }, + UnsupportedContextSchema { + side: &'static str, + found: u32, + }, + UnsupportedLockSchema { + side: &'static str, + found: u32, + }, + SubjectPackageMissing { + side: &'static str, + identity: String, + }, + SubjectPackageAmbiguous { + side: &'static str, + identity: String, + }, + ArtifactMissing { + side: &'static str, + file: String, + }, + ArtifactAmbiguous { + side: &'static str, + file: String, + }, + ConflictingResourceFilename { + resource: String, + side: &'static str, + }, + InconsistentResolvedReference { + side: &'static str, + canonical: String, + candidates: usize, + }, } impl fmt::Display for ImpactError { From a94576004c0066d809f751c2dea4596cfe5ea606 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:14:25 +0300 Subject: [PATCH 7/9] style(impact): format traversal implementation --- crates/commandf-pkg/src/impact.rs | 32 ++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/commandf-pkg/src/impact.rs b/crates/commandf-pkg/src/impact.rs index cd189021..65e6fe7c 100644 --- a/crates/commandf-pkg/src/impact.rs +++ b/crates/commandf-pkg/src/impact.rs @@ -5,9 +5,8 @@ use crate::{ ContextCanonicalReferenceEdge, ContextGraphReport, ContextPackageDependencyEdge, ContextPackageIdentity, ContextPackageNode, ImpactArtifactPathStep, ImpactArtifactRelation, ImpactCoverage, ImpactError, ImpactGraphEvidence, ImpactPackagePathStep, ImpactPackageRelation, - ImpactReport, ImpactSeed, ImpactSeedKind, ImpactSide, ImpactSubject, - ImpactUnresolvedBoundary, Lockfile, ResourceKey, ResourceKeyKind, StructuralChangeKind, - StructuralDiffReport, + ImpactReport, ImpactSeed, ImpactSeedKind, ImpactSide, ImpactSubject, ImpactUnresolvedBoundary, + Lockfile, ResourceKey, ResourceKeyKind, StructuralChangeKind, StructuralDiffReport, }; #[derive(Default)] @@ -324,7 +323,9 @@ fn artifact_for_filename( let matches = graph .artifacts .iter() - .filter(|artifact| artifact.identity.package == *package && artifact.identity.filename == filename) + .filter(|artifact| { + artifact.identity.package == *package && artifact.identity.filename == filename + }) .collect::>(); match matches.as_slice() { [artifact] => Ok(Some((*artifact).clone())), @@ -359,7 +360,8 @@ fn collect_artifact_side( } for edge in &graph.canonical_reference_edges { - if !sources.contains(&edge.source) || edge.resolution == CanonicalResolutionStatus::Resolved { + if !sources.contains(&edge.source) || edge.resolution == CanonicalResolutionStatus::Resolved + { continue; } boundaries.push(ImpactUnresolvedBoundary { @@ -383,7 +385,8 @@ fn artifact_paths( side: ImpactSide, ) -> Result>, ImpactError> { let label = side_label(side); - let mut reverse = BTreeMap::>::new(); + let mut reverse = + BTreeMap::>::new(); for edge in &graph.canonical_reference_edges { if edge.resolution != CanonicalResolutionStatus::Resolved { continue; @@ -459,7 +462,10 @@ fn package_paths( ) -> BTreeMap> { let mut reverse = BTreeMap::>::new(); for edge in &graph.package_dependency_edges { - reverse.entry(edge.to.clone()).or_default().push(edge.clone()); + reverse + .entry(edge.to.clone()) + .or_default() + .push(edge.clone()); } for edges in reverse.values_mut() { edges.sort(); @@ -504,7 +510,11 @@ fn is_better_path(existing: Option<&Vec>, candidate: &Vec) -> bool fn normalize_artifact_sides(input: Vec) -> Vec { let mut grouped = BTreeMap::< - (ContextArtifactIdentity, ContextArtifactIdentity, Vec), + ( + ContextArtifactIdentity, + ContextArtifactIdentity, + Vec, + ), BTreeSet, >::new(); for relation in input { @@ -528,7 +538,11 @@ fn normalize_artifact_sides(input: Vec) -> Vec) -> Vec { let mut grouped = BTreeMap::< - (ContextPackageIdentity, ContextPackageIdentity, Vec), + ( + ContextPackageIdentity, + ContextPackageIdentity, + Vec, + ), BTreeSet, >::new(); for relation in input { From 8b351b68036e5ff9d7223ce12f4c2263dc226adf Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 07:15:02 +0300 Subject: [PATCH 8/9] style(impact): format contract tests --- crates/commandf-pkg/tests/impact.rs | 153 +++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 23 deletions(-) diff --git a/crates/commandf-pkg/tests/impact.rs b/crates/commandf-pkg/tests/impact.rs index d760a01f..f5d42d8f 100644 --- a/crates/commandf-pkg/tests/impact.rs +++ b/crates/commandf-pkg/tests/impact.rs @@ -12,8 +12,18 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { let after_subject = package("acme.changed", "2.0.0", "subject-after"); let dependent = package("acme.dep", "1.0.0", "dep"); - let before_seed = artifact(&before_subject, "seed.json", "seed-before", Some("https://example.org/B")); - let after_seed = artifact(&after_subject, "seed.json", "seed-after", Some("https://example.org/B")); + let before_seed = artifact( + &before_subject, + "seed.json", + "seed-before", + Some("https://example.org/B"), + ); + let after_seed = artifact( + &after_subject, + "seed.json", + "seed-after", + Some("https://example.org/B"), + ); let a = artifact(&dependent, "a.json", "a", Some("https://example.org/A")); let x = artifact(&dependent, "x.json", "x", Some("https://example.org/X")); @@ -26,7 +36,11 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { resolved(&x, &a, "https://example.org/A"), resolved(&a, &x, "https://example.org/X"), external(&a, "https://external.example/ValueSet/missing"), - ambiguous(&x, "https://example.org/ambiguous", vec![a.identity.clone(), before_seed.identity.clone()]), + ambiguous( + &x, + "https://example.org/ambiguous", + vec![a.identity.clone(), before_seed.identity.clone()], + ), ], ); let after = graph( @@ -38,10 +52,19 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { resolved(&x, &a, "https://example.org/A"), resolved(&a, &x, "https://example.org/X"), external(&a, "https://external.example/ValueSet/missing"), - ambiguous(&x, "https://example.org/ambiguous", vec![a.identity.clone(), after_seed.identity.clone()]), + ambiguous( + &x, + "https://example.org/ambiguous", + vec![a.identity.clone(), after_seed.identity.clone()], + ), ], ); - let diff = modified_diff(&before_subject, &after_subject, "https://example.org/B", "seed.json"); + let diff = modified_diff( + &before_subject, + &after_subject, + "https://example.org/B", + "seed.json", + ); let report = build_impact_report(&diff, &before, &after).unwrap(); @@ -49,10 +72,14 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { assert_eq!(report.seeds[0].kind, ImpactSeedKind::Modified); assert_eq!(report.artifact_impacts.len(), 4); assert!(report.artifact_impacts.iter().any(|impact| { - impact.impacted == a.identity && impact.side == ImpactSide::Before && impact.path.len() == 1 + impact.impacted == a.identity + && impact.side == ImpactSide::Before + && impact.path.len() == 1 })); assert!(report.artifact_impacts.iter().any(|impact| { - impact.impacted == x.identity && impact.side == ImpactSide::After && impact.path.len() == 2 + impact.impacted == x.identity + && impact.side == ImpactSide::After + && impact.path.len() == 2 })); assert_eq!(report.package_impacts.len(), 2); assert_eq!(report.unresolved_boundaries.len(), 4); @@ -73,7 +100,12 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { fn chooses_lexicographically_first_equal_length_shortest_path() { let subject = package("acme.changed", "1.0.0", "subject"); let dep = package("acme.dep", "1.0.0", "dep"); - let seed = artifact(&subject, "seed.json", "seed", Some("https://example.org/Seed")); + let seed = artifact( + &subject, + "seed.json", + "seed", + Some("https://example.org/Seed"), + ); let a = artifact(&dep, "a.json", "a", Some("https://example.org/A")); let c = artifact(&dep, "c.json", "c", Some("https://example.org/C")); let d = artifact(&dep, "d.json", "d", Some("https://example.org/D")); @@ -88,7 +120,12 @@ fn chooses_lexicographically_first_equal_length_shortest_path() { resolved(&d, &c, "https://example.org/C"), ], ); - let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); + let diff = modified_diff( + &subject, + &subject, + "https://example.org/Seed", + "seed.json", + ); let report = build_impact_report(&diff, &graph, &graph).unwrap(); let d_impact = report @@ -106,10 +143,30 @@ fn chooses_lexicographically_first_equal_length_shortest_path() { fn preserves_added_and_removed_canonical_seeds_on_their_evidence_side() { let before_subject = package("acme.changed", "1.0.0", "before"); let after_subject = package("acme.changed", "2.0.0", "after"); - let removed = artifact(&before_subject, "removed.json", "removed", Some("https://example.org/Removed")); - let added = artifact(&after_subject, "added.json", "added", Some("https://example.org/Added")); - let before = graph(vec![before_subject.clone()], vec![removed.clone()], Vec::new(), Vec::new()); - let after = graph(vec![after_subject.clone()], vec![added.clone()], Vec::new(), Vec::new()); + let removed = artifact( + &before_subject, + "removed.json", + "removed", + Some("https://example.org/Removed"), + ); + let added = artifact( + &after_subject, + "added.json", + "added", + Some("https://example.org/Added"), + ); + let before = graph( + vec![before_subject.clone()], + vec![removed.clone()], + Vec::new(), + Vec::new(), + ); + let after = graph( + vec![after_subject.clone()], + vec![added.clone()], + Vec::new(), + Vec::new(), + ); let diff = StructuralDiffReport { schema: StructuralDiffReport::SCHEMA_V1, package_name: "acme.changed".to_owned(), @@ -159,10 +216,30 @@ fn preserves_added_and_removed_canonical_seeds_on_their_evidence_side() { fn canonical_url_change_becomes_removed_and_added_seed() { let before_subject = package("acme.changed", "1.0.0", "before"); let after_subject = package("acme.changed", "2.0.0", "after"); - let before_artifact = artifact(&before_subject, "profile.json", "old", Some("https://example.org/Old")); - let after_artifact = artifact(&after_subject, "profile.json", "new", Some("https://example.org/New")); - let before = graph(vec![before_subject.clone()], vec![before_artifact], Vec::new(), Vec::new()); - let after = graph(vec![after_subject.clone()], vec![after_artifact], Vec::new(), Vec::new()); + let before_artifact = artifact( + &before_subject, + "profile.json", + "old", + Some("https://example.org/Old"), + ); + let after_artifact = artifact( + &after_subject, + "profile.json", + "new", + Some("https://example.org/New"), + ); + let before = graph( + vec![before_subject.clone()], + vec![before_artifact], + Vec::new(), + Vec::new(), + ); + let after = graph( + vec![after_subject.clone()], + vec![after_artifact], + Vec::new(), + Vec::new(), + ); let diff = StructuralDiffReport { schema: 1, package_name: "acme.changed".to_owned(), @@ -204,9 +281,19 @@ fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { let dep_v1 = package("acme.dep", "1.0.0", "dep-v1"); let dep_v2 = package("acme.dep", "2.0.0", "dep-v2"); let root = package("acme.root", "1.0.0", "root"); - let seed = artifact(&subject, "seed.json", "seed", Some("https://example.org/Seed")); + let seed = artifact( + &subject, + "seed.json", + "seed", + Some("https://example.org/Seed"), + ); let graph = graph( - vec![subject.clone(), dep_v1.clone(), dep_v2.clone(), root.clone()], + vec![ + subject.clone(), + dep_v1.clone(), + dep_v2.clone(), + root.clone(), + ], vec![seed], vec![ dependency(&dep_v1, &subject, "2.0.0"), @@ -217,7 +304,12 @@ fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { ], Vec::new(), ); - let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); + let diff = modified_diff( + &subject, + &subject, + "https://example.org/Seed", + "seed.json", + ); let report = build_impact_report(&diff, &graph, &graph).unwrap(); let identities = report @@ -239,8 +331,18 @@ fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { fn does_not_traverse_ambiguous_or_external_reference_edges() { let subject = package("acme.changed", "1.0.0", "subject"); let dep = package("acme.dep", "1.0.0", "dep"); - let seed = artifact(&subject, "seed.json", "seed", Some("https://example.org/Seed")); - let source = artifact(&dep, "source.json", "source", Some("https://example.org/Source")); + let seed = artifact( + &subject, + "seed.json", + "seed", + Some("https://example.org/Seed"), + ); + let source = artifact( + &dep, + "source.json", + "source", + Some("https://example.org/Source"), + ); let graph = graph( vec![subject.clone(), dep], vec![seed.clone(), source.clone()], @@ -255,7 +357,12 @@ fn does_not_traverse_ambiguous_or_external_reference_edges() { candidates: vec![seed.identity.clone(), source.identity.clone()], }], ); - let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); + let diff = modified_diff( + &subject, + &subject, + "https://example.org/Seed", + "seed.json", + ); let report = build_impact_report(&diff, &graph, &graph).unwrap(); assert!(report.artifact_impacts.is_empty()); From 9fa948cb2ad0110cd4288c330a5bc8b977472418 Mon Sep 17 00:00:00 2001 From: "Abdulaziz M. Shehri" Date: Wed, 26 Aug 2026 09:49:50 +0300 Subject: [PATCH 9/9] test(impact): normalize CF-12 contract fixtures --- crates/commandf-pkg/tests/impact.rs | 153 +++++++++++----------------- 1 file changed, 57 insertions(+), 96 deletions(-) diff --git a/crates/commandf-pkg/tests/impact.rs b/crates/commandf-pkg/tests/impact.rs index f5d42d8f..106b1812 100644 --- a/crates/commandf-pkg/tests/impact.rs +++ b/crates/commandf-pkg/tests/impact.rs @@ -7,7 +7,7 @@ use commandf_pkg::{ }; #[test] -fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { +fn reports_transitive_cycle_boundaries_and_determinism() { let before_subject = package("acme.changed", "1.0.0", "subject-before"); let after_subject = package("acme.changed", "2.0.0", "subject-after"); let dependent = package("acme.dep", "1.0.0", "dep"); @@ -72,14 +72,10 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { assert_eq!(report.seeds[0].kind, ImpactSeedKind::Modified); assert_eq!(report.artifact_impacts.len(), 4); assert!(report.artifact_impacts.iter().any(|impact| { - impact.impacted == a.identity - && impact.side == ImpactSide::Before - && impact.path.len() == 1 + impact.impacted == a.identity && impact.side == ImpactSide::Before && impact.path.len() == 1 })); assert!(report.artifact_impacts.iter().any(|impact| { - impact.impacted == x.identity - && impact.side == ImpactSide::After - && impact.path.len() == 2 + impact.impacted == x.identity && impact.side == ImpactSide::After && impact.path.len() == 2 })); assert_eq!(report.package_impacts.len(), 2); assert_eq!(report.unresolved_boundaries.len(), 4); @@ -97,20 +93,20 @@ fn reports_direct_transitive_cycle_and_unresolved_artifact_exposure() { } #[test] -fn chooses_lexicographically_first_equal_length_shortest_path() { +fn chooses_lexicographically_first_shortest_path() { let subject = package("acme.changed", "1.0.0", "subject"); - let dep = package("acme.dep", "1.0.0", "dep"); + let dependent = package("acme.dep", "1.0.0", "dep"); let seed = artifact( &subject, "seed.json", "seed", Some("https://example.org/Seed"), ); - let a = artifact(&dep, "a.json", "a", Some("https://example.org/A")); - let c = artifact(&dep, "c.json", "c", Some("https://example.org/C")); - let d = artifact(&dep, "d.json", "d", Some("https://example.org/D")); - let graph = graph( - vec![subject.clone(), dep], + let a = artifact(&dependent, "a.json", "a", Some("https://example.org/A")); + let c = artifact(&dependent, "c.json", "c", Some("https://example.org/C")); + let d = artifact(&dependent, "d.json", "d", Some("https://example.org/D")); + let context = graph( + vec![subject.clone(), dependent], vec![seed.clone(), a.clone(), c.clone(), d.clone()], Vec::new(), vec![ @@ -120,27 +116,22 @@ fn chooses_lexicographically_first_equal_length_shortest_path() { resolved(&d, &c, "https://example.org/C"), ], ); - let diff = modified_diff( - &subject, - &subject, - "https://example.org/Seed", - "seed.json", - ); + let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); - let report = build_impact_report(&diff, &graph, &graph).unwrap(); - let d_impact = report + let report = build_impact_report(&diff, &context, &context).unwrap(); + let impact = report .artifact_impacts .iter() .find(|impact| impact.impacted == d.identity) .unwrap(); - assert_eq!(d_impact.side, ImpactSide::Both); - assert_eq!(d_impact.path.len(), 2); - assert_eq!(d_impact.path[0].target, a.identity); + assert_eq!(impact.side, ImpactSide::Both); + assert_eq!(impact.path.len(), 2); + assert_eq!(impact.path[0].target, a.identity); } #[test] -fn preserves_added_and_removed_canonical_seeds_on_their_evidence_side() { +fn preserves_added_removed_and_changed_canonical_identity() { let before_subject = package("acme.changed", "1.0.0", "before"); let after_subject = package("acme.changed", "2.0.0", "after"); let removed = artifact( @@ -170,14 +161,8 @@ fn preserves_added_and_removed_canonical_seeds_on_their_evidence_side() { let diff = StructuralDiffReport { schema: StructuralDiffReport::SCHEMA_V1, package_name: "acme.changed".to_owned(), - before: PackageEvidence { - version: before_subject.version.clone(), - archive_sha256: before_subject.sha256.clone(), - }, - after: PackageEvidence { - version: after_subject.version.clone(), - archive_sha256: after_subject.sha256.clone(), - }, + before: evidence(&before_subject), + after: evidence(&after_subject), changes: vec![ change( StructuralChangeKind::ResourceRemoved, @@ -196,33 +181,24 @@ fn preserves_added_and_removed_canonical_seeds_on_their_evidence_side() { let report = build_impact_report(&diff, &before, &after).unwrap(); assert_eq!(report.seeds.len(), 2); - let added_seed = report - .seeds - .iter() - .find(|seed| seed.kind == ImpactSeedKind::Added) - .unwrap(); - assert!(added_seed.before.is_none()); - assert_eq!(added_seed.after.as_ref(), Some(&added.identity)); - let removed_seed = report - .seeds - .iter() - .find(|seed| seed.kind == ImpactSeedKind::Removed) - .unwrap(); - assert_eq!(removed_seed.before.as_ref(), Some(&removed.identity)); - assert!(removed_seed.after.is_none()); -} + assert!(report.seeds.iter().any(|seed| { + seed.kind == ImpactSeedKind::Added + && seed.before.is_none() + && seed.after.as_ref() == Some(&added.identity) + })); + assert!(report.seeds.iter().any(|seed| { + seed.kind == ImpactSeedKind::Removed + && seed.before.as_ref() == Some(&removed.identity) + && seed.after.is_none() + })); -#[test] -fn canonical_url_change_becomes_removed_and_added_seed() { - let before_subject = package("acme.changed", "1.0.0", "before"); - let after_subject = package("acme.changed", "2.0.0", "after"); - let before_artifact = artifact( + let old = artifact( &before_subject, "profile.json", "old", Some("https://example.org/Old"), ); - let after_artifact = artifact( + let new = artifact( &after_subject, "profile.json", "new", @@ -230,27 +206,21 @@ fn canonical_url_change_becomes_removed_and_added_seed() { ); let before = graph( vec![before_subject.clone()], - vec![before_artifact], + vec![old], Vec::new(), Vec::new(), ); let after = graph( vec![after_subject.clone()], - vec![after_artifact], + vec![new], Vec::new(), Vec::new(), ); let diff = StructuralDiffReport { - schema: 1, + schema: StructuralDiffReport::SCHEMA_V1, package_name: "acme.changed".to_owned(), - before: PackageEvidence { - version: before_subject.version.clone(), - archive_sha256: before_subject.sha256.clone(), - }, - after: PackageEvidence { - version: after_subject.version.clone(), - archive_sha256: after_subject.sha256.clone(), - }, + before: evidence(&before_subject), + after: evidence(&after_subject), changes: vec![StructuralChange { kind: StructuralChangeKind::ResourceBytesChanged, resource: ResourceKey { @@ -276,7 +246,7 @@ fn canonical_url_change_becomes_removed_and_added_seed() { } #[test] -fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { +fn keeps_same_name_package_versions_distinct_and_terminates_cycles() { let subject = package("acme.changed", "2.0.0", "subject"); let dep_v1 = package("acme.dep", "1.0.0", "dep-v1"); let dep_v2 = package("acme.dep", "2.0.0", "dep-v2"); @@ -287,7 +257,7 @@ fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { "seed", Some("https://example.org/Seed"), ); - let graph = graph( + let context = graph( vec![ subject.clone(), dep_v1.clone(), @@ -304,19 +274,15 @@ fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { ], Vec::new(), ); - let diff = modified_diff( - &subject, - &subject, - "https://example.org/Seed", - "seed.json", - ); + let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); - let report = build_impact_report(&diff, &graph, &graph).unwrap(); + let report = build_impact_report(&diff, &context, &context).unwrap(); let identities = report .package_impacts .iter() .map(|impact| impact.impacted.clone()) .collect::>(); + assert!(identities.contains(&dep_v1)); assert!(identities.contains(&dep_v2)); assert!(identities.contains(&root)); @@ -328,9 +294,9 @@ fn package_exposure_keeps_same_name_versions_distinct_and_terminates_cycles() { } #[test] -fn does_not_traverse_ambiguous_or_external_reference_edges() { +fn never_traverses_ambiguous_reference_as_resolved() { let subject = package("acme.changed", "1.0.0", "subject"); - let dep = package("acme.dep", "1.0.0", "dep"); + let dependent = package("acme.dep", "1.0.0", "dep"); let seed = artifact( &subject, "seed.json", @@ -338,13 +304,13 @@ fn does_not_traverse_ambiguous_or_external_reference_edges() { Some("https://example.org/Seed"), ); let source = artifact( - &dep, + &dependent, "source.json", "source", Some("https://example.org/Source"), ); - let graph = graph( - vec![subject.clone(), dep], + let context = graph( + vec![subject.clone(), dependent], vec![seed.clone(), source.clone()], Vec::new(), vec![ContextCanonicalReferenceEdge { @@ -357,16 +323,10 @@ fn does_not_traverse_ambiguous_or_external_reference_edges() { candidates: vec![seed.identity.clone(), source.identity.clone()], }], ); - let diff = modified_diff( - &subject, - &subject, - "https://example.org/Seed", - "seed.json", - ); + let diff = modified_diff(&subject, &subject, "https://example.org/Seed", "seed.json"); - let report = build_impact_report(&diff, &graph, &graph).unwrap(); + let report = build_impact_report(&diff, &context, &context).unwrap(); assert!(report.artifact_impacts.is_empty()); - assert!(report.unresolved_boundaries.is_empty()); } fn modified_diff( @@ -378,14 +338,8 @@ fn modified_diff( StructuralDiffReport { schema: StructuralDiffReport::SCHEMA_V1, package_name: before.name.clone(), - before: PackageEvidence { - version: before.version.clone(), - archive_sha256: before.sha256.clone(), - }, - after: PackageEvidence { - version: after.version.clone(), - archive_sha256: after.sha256.clone(), - }, + before: evidence(before), + after: evidence(after), changes: vec![change( StructuralChangeKind::ResourceBytesChanged, canonical, @@ -395,6 +349,13 @@ fn modified_diff( } } +fn evidence(package: &ContextPackageIdentity) -> PackageEvidence { + PackageEvidence { + version: package.version.clone(), + archive_sha256: package.sha256.clone(), + } +} + fn change( kind: StructuralChangeKind, canonical: &str,