diff --git a/crates/commandf-pkg/src/impact.rs b/crates/commandf-pkg/src/impact.rs new file mode 100644 index 00000000..65e6fe7c --- /dev/null +++ b/crates/commandf-pkg/src/impact.rs @@ -0,0 +1,642 @@ +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", + } +} diff --git a/crates/commandf-pkg/src/impact_error.rs b/crates/commandf-pkg/src/impact_error.rs new file mode 100644 index 00000000..ddf72b38 --- /dev/null +++ b/crates/commandf-pkg/src/impact_error.rs @@ -0,0 +1,83 @@ +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 {} 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) + } +} 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; diff --git a/crates/commandf-pkg/tests/impact.rs b/crates/commandf-pkg/tests/impact.rs new file mode 100644 index 00000000..106b1812 --- /dev/null +++ b/crates/commandf-pkg/tests/impact.rs @@ -0,0 +1,491 @@ +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_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"); + + 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_shortest_path() { + let subject = package("acme.changed", "1.0.0", "subject"); + let dependent = package("acme.dep", "1.0.0", "dep"); + let seed = artifact( + &subject, + "seed.json", + "seed", + Some("https://example.org/Seed"), + ); + 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![ + 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, &context, &context).unwrap(); + let impact = report + .artifact_impacts + .iter() + .find(|impact| impact.impacted == d.identity) + .unwrap(); + + assert_eq!(impact.side, ImpactSide::Both); + assert_eq!(impact.path.len(), 2); + assert_eq!(impact.path[0].target, a.identity); +} + +#[test] +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( + &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: evidence(&before_subject), + after: evidence(&after_subject), + 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); + 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() + })); + + let old = artifact( + &before_subject, + "profile.json", + "old", + Some("https://example.org/Old"), + ); + let new = artifact( + &after_subject, + "profile.json", + "new", + Some("https://example.org/New"), + ); + let before = graph( + vec![before_subject.clone()], + vec![old], + Vec::new(), + Vec::new(), + ); + let after = graph( + vec![after_subject.clone()], + vec![new], + Vec::new(), + Vec::new(), + ); + let diff = StructuralDiffReport { + schema: StructuralDiffReport::SCHEMA_V1, + package_name: "acme.changed".to_owned(), + before: evidence(&before_subject), + after: evidence(&after_subject), + 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 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"); + let root = package("acme.root", "1.0.0", "root"); + let seed = artifact( + &subject, + "seed.json", + "seed", + Some("https://example.org/Seed"), + ); + let context = 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, &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)); + assert_eq!(identities.len(), 3); + assert!(report + .package_impacts + .iter() + .all(|impact| impact.side == ImpactSide::Both)); +} + +#[test] +fn never_traverses_ambiguous_reference_as_resolved() { + let subject = package("acme.changed", "1.0.0", "subject"); + let dependent = package("acme.dep", "1.0.0", "dep"); + let seed = artifact( + &subject, + "seed.json", + "seed", + Some("https://example.org/Seed"), + ); + let source = artifact( + &dependent, + "source.json", + "source", + Some("https://example.org/Source"), + ); + let context = graph( + vec![subject.clone(), dependent], + 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, &context, &context).unwrap(); + assert!(report.artifact_impacts.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: evidence(before), + after: evidence(after), + changes: vec![change( + StructuralChangeKind::ResourceBytesChanged, + canonical, + Some(filename), + Some(filename), + )], + } +} + +fn evidence(package: &ContextPackageIdentity) -> PackageEvidence { + PackageEvidence { + version: package.version.clone(), + archive_sha256: package.sha256.clone(), + } +} + +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, + } +}