1use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, VecDeque};
5
6use code_system_graph_model::{
7 CommunityId, CommunitySnapshot, Edge, EdgeId, EdgeKind, EpistemicStatus, EvidenceId, Node, NodeId, NodeKind, RepoFreshness, RepoFreshnessState, RepoId
8};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13const RISK_MODEL_VERSION: &str = "1.0.0";
14const MAX_DEPTH: usize = 128;
15const MAX_NODES: usize = 100_000;
16const MAX_EDGES: usize = 1_000_000;
17const MAX_LIMIT: usize = 10_000;
18const MAX_OFFSET: usize = 1_000_000;
19
20#[derive(
22 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
23)]
24#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
25pub enum RiskLevel {
26 Low,
28 Medium,
30 High,
32 Critical,
34 Unknown,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum ImpactDirection {
42 Upstream,
44 Downstream,
46 Both,
48}
49
50#[derive(
52 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
53)]
54#[serde(rename_all = "snake_case")]
55pub enum ImpactClassification {
56 DirectlyDependent,
58 TransitivelyAffected,
60 PossiblyAffected,
62 UnknownDueToCoverage,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
69pub enum ImpactTarget {
70 NodeId(NodeId),
72 StableKey(String),
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
78pub struct ResolvedTarget {
79 pub node: Node,
81 pub resolved_by: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
87pub struct ImpactPathStep {
88 pub from: NodeId,
90 pub to: NodeId,
92 pub edge_id: EdgeId,
94 pub kind: EdgeKind,
96 pub reversed: bool,
98 pub confidence: f32,
100 pub status: EpistemicStatus,
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
106pub struct ImpactItem {
107 pub node: Node,
109 pub classification: ImpactClassification,
111 pub depth: usize,
113 pub path: Vec<ImpactPathStep>,
115 pub evidence: Vec<EvidenceId>,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
121pub struct RiskFactor {
122 pub code: String,
124 pub weight: f32,
126 pub explanation: String,
128 pub evidence: Vec<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134pub struct RepositoryImpact {
135 pub repo_id: RepoId,
137 pub classification: ImpactClassification,
139 pub minimum_depth: usize,
141 pub affected_nodes: usize,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
147pub struct ServiceImpact {
148 pub service: Node,
150 pub classification: ImpactClassification,
152 pub minimum_depth: usize,
154 pub affected_nodes: usize,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160pub struct ContractImpact {
161 pub contract: Node,
163 pub classification: ImpactClassification,
165 pub depth: usize,
167 pub public: bool,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
173pub struct CommunityImpact {
174 pub community_id: CommunityId,
176 pub label: String,
178 pub classification: ImpactClassification,
180 pub affected_members: usize,
182 pub coupling: f64,
184 pub limitations: Vec<String>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190#[serde(rename_all = "snake_case")]
191pub enum LocalEnrichmentStatus {
192 Available,
194 Partial,
196 Stale,
198 Unavailable,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204pub struct LocalImpactItem {
205 pub symbol: String,
207 pub file_path: String,
209 pub start_line: Option<usize>,
211 pub depth: usize,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
217pub struct LocalEnrichmentInput {
218 pub repo_id: RepoId,
220 pub anchor: String,
222 pub status: LocalEnrichmentStatus,
224 pub affected: Vec<LocalImpactItem>,
226 pub affected_tests: Vec<String>,
228 pub truncated: bool,
230 pub degradations: Vec<String>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct LocalImpactSummary {
237 pub repo_id: RepoId,
239 pub anchor: String,
241 pub status: LocalEnrichmentStatus,
243 pub affected_count: usize,
245 pub maximum_depth: usize,
247 pub truncated: bool,
249 pub degradations: Vec<String>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255#[serde(rename_all = "snake_case")]
256pub enum TestRecommendationSource {
257 Graph,
259 LocalEnrichment,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
265pub struct TestRecommendation {
266 pub test_node_id: Option<NodeId>,
268 pub test: String,
270 pub repo_id: Option<RepoId>,
272 pub source: TestRecommendationSource,
274 pub rank: usize,
276 pub reasons: Vec<String>,
278 pub owners: Vec<Node>,
280 pub recommended_commands: Vec<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286pub struct CoverageSummary {
287 pub sufficient_for_score: bool,
289 pub relevant_repositories: Vec<RepoId>,
291 pub fresh_repositories: Vec<RepoId>,
293 pub stale_repositories: Vec<RepoId>,
295 pub partial_repositories: Vec<RepoId>,
297 pub unavailable_repositories: Vec<RepoId>,
299 pub missing_repositories: Vec<RepoId>,
301 pub possible_edges: usize,
303 pub unknown_edges: usize,
305 pub gaps: Vec<String>,
307 pub remediation: Vec<String>,
309 pub total_items: usize,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
315pub struct TruncationInfo {
316 pub bound: String,
318 pub limit: usize,
320 pub observed: usize,
322 pub explanation: String,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328pub struct ImpactDepthBucket {
329 pub depth: usize,
331 pub directly_dependent: usize,
333 pub transitively_affected: usize,
335 pub possibly_affected: usize,
337 pub unknown_due_to_coverage: usize,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
343pub struct ImpactOptions {
344 #[serde(default = "default_impact_max_depth")]
346 pub max_depth: usize,
347 #[serde(default = "default_impact_node_limit")]
349 pub node_limit: usize,
350 #[serde(default = "default_impact_edge_limit")]
352 pub edge_limit: usize,
353 #[serde(default = "default_confirmed_confidence")]
355 pub confirmed_confidence: f32,
356 #[serde(default)]
358 pub offset: usize,
359 #[serde(default = "default_impact_limit")]
361 pub limit: usize,
362 #[serde(default)]
364 pub summary_only: bool,
365 #[serde(default = "default_include_depth_buckets")]
367 pub include_depth_buckets: bool,
368}
369
370impl Default for ImpactOptions {
371 fn default() -> Self {
372 Self {
373 max_depth: default_impact_max_depth(),
374 node_limit: default_impact_node_limit(),
375 edge_limit: default_impact_edge_limit(),
376 confirmed_confidence: default_confirmed_confidence(),
377 offset: 0,
378 limit: default_impact_limit(),
379 summary_only: false,
380 include_depth_buckets: default_include_depth_buckets(),
381 }
382 }
383}
384
385const fn default_impact_max_depth() -> usize {
386 8
387}
388
389const fn default_impact_node_limit() -> usize {
390 10_000
391}
392
393const fn default_impact_edge_limit() -> usize {
394 50_000
395}
396
397const fn default_confirmed_confidence() -> f32 {
398 0.8
399}
400
401const fn default_impact_limit() -> usize {
402 100
403}
404
405const fn default_include_depth_buckets() -> bool {
406 true
407}
408
409#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
411pub struct ImpactRequest {
412 pub target: ImpactTarget,
414 pub direction: ImpactDirection,
416 #[serde(default)]
418 pub options: ImpactOptions,
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
423#[serde(rename_all = "snake_case")]
424pub enum ImpactCompatibilityStatus {
425 Breaking,
427 PotentiallyBreaking,
429 Compatible,
431 Unknown,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
437pub struct CompatibilityInput {
438 pub contract_node_id: NodeId,
440 pub status: ImpactCompatibilityStatus,
442 pub evidence: Vec<String>,
444 pub recommended_validations: Vec<String>,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
450#[serde(rename_all = "snake_case")]
451pub enum CriticalityTag {
452 Critical,
454 Authentication,
456 Security,
458 Payment,
460 DataBoundary,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
466pub struct CriticalityAssignment {
467 pub node_id: NodeId,
469 pub tag: CriticalityTag,
471 pub evidence: Vec<String>,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
477pub struct EnvironmentAssignment {
478 pub node_id: NodeId,
480 pub environment: String,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
486pub struct RecommendedCommand {
487 pub repo_id: RepoId,
489 pub command: String,
491 pub description: String,
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
497pub struct ImpactContext {
498 pub nodes: Vec<Node>,
500 pub edges: Vec<Edge>,
502 pub communities: Option<CommunitySnapshot>,
504 pub freshness: Vec<RepoFreshness>,
506 pub compatibility: Vec<CompatibilityInput>,
508 pub local_enrichment: Vec<LocalEnrichmentInput>,
510 pub public_contracts: Vec<NodeId>,
512 pub criticality: Vec<CriticalityAssignment>,
514 pub centrality: BTreeMap<NodeId, f32>,
516 pub service_memberships: BTreeMap<NodeId, Vec<NodeId>>,
518 pub environments: Vec<EnvironmentAssignment>,
520 pub recommended_commands: Vec<RecommendedCommand>,
522 pub graph_complete: bool,
524 pub coverage_gaps: Vec<String>,
526}
527
528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
530pub struct ImpactReport {
531 pub risk_model_version: String,
533 pub target: ResolvedTarget,
535 pub direction: ImpactDirection,
537 pub risk: RiskLevel,
539 pub risk_score: Option<f32>,
541 pub reasons: Vec<RiskFactor>,
543 pub direct_consumers: Vec<ImpactItem>,
545 pub transitive_consumers: Vec<ImpactItem>,
547 pub possibly_affected: Vec<ImpactItem>,
549 pub unknown_due_to_coverage: Vec<ImpactItem>,
551 pub affected_repositories: Vec<RepositoryImpact>,
553 pub affected_services: Vec<ServiceImpact>,
555 pub affected_contracts: Vec<ContractImpact>,
557 pub affected_communities: Vec<CommunityImpact>,
559 pub local_impact_summaries: Vec<LocalImpactSummary>,
561 pub test_recommendations: Vec<TestRecommendation>,
563 pub depth_buckets: Vec<ImpactDepthBucket>,
565 pub coverage: CoverageSummary,
567 pub truncation: Option<TruncationInfo>,
569}
570
571#[derive(Debug, Error, PartialEq)]
573pub enum ImpactError {
574 #[error("duplicate node identifier `{0}`")]
576 DuplicateNode(String),
577 #[error("duplicate edge identifier `{0}`")]
579 DuplicateEdge(String),
580 #[error("duplicate freshness record for repository `{0}`")]
582 DuplicateFreshness(String),
583 #[error("edge `{edge}` references missing node `{node}`")]
585 DanglingEdge {
586 edge: String,
588 node: String,
590 },
591 #[error("edge `{0}` confidence must be finite and in the inclusive range 0..=1")]
593 InvalidEdgeConfidence(String),
594 #[error("confirmed confidence must be finite and in the inclusive range 0..=1")]
596 InvalidConfirmedConfidence,
597 #[error("impact traversal or pagination bounds are outside supported limits")]
599 InvalidBounds,
600 #[error("centrality for node `{0}` must be finite and in the inclusive range 0..=1")]
602 InvalidCentrality(String),
603 #[error("criticality assignment for node `{0}` requires explicit evidence")]
605 CriticalityWithoutEvidence(String),
606 #[error("{context} references missing node `{node}`")]
608 UnknownContextNode {
609 context: &'static str,
611 node: String,
613 },
614 #[error("impact target was not found")]
616 UnknownTarget,
617 #[error("stable key `{0}` resolves to more than one node")]
619 AmbiguousTarget(String),
620}
621
622#[derive(Debug, Clone)]
623struct TraversalState {
624 node_id: NodeId,
625 certainty: PathCertainty,
626 path: Vec<ImpactPathStep>,
627 evidence: BTreeSet<EvidenceId>,
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
631enum PathCertainty {
632 Confirmed,
633 Possible,
634 Unknown,
635}
636
637#[derive(Debug, Clone)]
638struct Adjacency<'a> {
639 neighbor: NodeId,
640 edge: &'a Edge,
641 reversed: bool,
642}
643
644#[derive(Debug, Clone, Copy)]
645struct Aggregate {
646 classification: ImpactClassification,
647 minimum_depth: usize,
648 affected_nodes: usize,
649}
650
651#[must_use = "impact reports and validation errors must be handled"]
661pub fn analyze_impact(
662 request: &ImpactRequest,
663 context: &ImpactContext,
664) -> Result<ImpactReport, ImpactError> {
665 let nodes = validate_context(request, context)?;
666 let target = resolve_target(&request.target, &nodes)?;
667 let (mut items, truncation, possible_edges, unknown_edges) =
668 traverse(request, context, &nodes, &target.node)?;
669 sort_items(&mut items);
670
671 let repositories = aggregate_repositories(&items);
672 let services = aggregate_services(&items, context, &nodes);
673 let contracts = aggregate_contracts(&items, context);
674 let communities = aggregate_communities(&items, context);
675 let local_summaries = summarize_local_enrichment(context);
676 let tests = recommend_tests(&items, context, &nodes);
677 let depth_buckets = if request.options.include_depth_buckets {
678 build_depth_buckets(&items)
679 } else {
680 Vec::new()
681 };
682 let coverage = coverage_summary(
683 &target.node,
684 &items,
685 context,
686 truncation.as_ref(),
687 possible_edges,
688 unknown_edges,
689 );
690 let (risk, risk_score, reasons) = assess_risk(
691 &target.node,
692 &items,
693 &repositories,
694 &services,
695 &contracts,
696 &communities,
697 &tests,
698 context,
699 &coverage,
700 truncation.as_ref(),
701 );
702
703 let page = if request.options.summary_only {
704 Vec::new()
705 } else {
706 items
707 .iter()
708 .skip(request.options.offset.min(items.len()))
709 .take(request.options.limit)
710 .cloned()
711 .collect()
712 };
713 let (direct, transitive, possible, unknown) = split_classifications(page);
714
715 Ok(ImpactReport {
716 risk_model_version: RISK_MODEL_VERSION.to_owned(),
717 target,
718 direction: request.direction,
719 risk,
720 risk_score,
721 reasons,
722 direct_consumers: direct,
723 transitive_consumers: transitive,
724 possibly_affected: possible,
725 unknown_due_to_coverage: unknown,
726 affected_repositories: repositories,
727 affected_services: services,
728 affected_contracts: contracts,
729 affected_communities: communities,
730 local_impact_summaries: local_summaries,
731 test_recommendations: tests,
732 depth_buckets,
733 coverage,
734 truncation,
735 })
736}
737
738fn validate_context<'a>(
739 request: &ImpactRequest,
740 context: &'a ImpactContext,
741) -> Result<BTreeMap<NodeId, &'a Node>, ImpactError> {
742 let options = &request.options;
743 if options.max_depth == 0
744 || options.max_depth > MAX_DEPTH
745 || options.node_limit == 0
746 || options.node_limit > MAX_NODES
747 || options.edge_limit == 0
748 || options.edge_limit > MAX_EDGES
749 || options.limit == 0
750 || options.limit > MAX_LIMIT
751 || options.offset > MAX_OFFSET
752 {
753 return Err(ImpactError::InvalidBounds);
754 }
755 if !options.confirmed_confidence.is_finite()
756 || !(0.0..=1.0).contains(&options.confirmed_confidence)
757 {
758 return Err(ImpactError::InvalidConfirmedConfidence);
759 }
760
761 let mut nodes = BTreeMap::new();
762 for node in &context.nodes {
763 if nodes.insert(node.id.clone(), node).is_some() {
764 return Err(ImpactError::DuplicateNode(node.id.as_str().to_owned()));
765 }
766 }
767 let mut edge_ids = BTreeSet::new();
768 for edge in &context.edges {
769 if !edge_ids.insert(edge.id.clone()) {
770 return Err(ImpactError::DuplicateEdge(edge.id.as_str().to_owned()));
771 }
772 if !edge.confidence.is_finite() || !(0.0..=1.0).contains(&edge.confidence) {
773 return Err(ImpactError::InvalidEdgeConfidence(
774 edge.id.as_str().to_owned(),
775 ));
776 }
777 for endpoint in [&edge.source, &edge.target] {
778 if !nodes.contains_key(endpoint) {
779 return Err(ImpactError::DanglingEdge {
780 edge: edge.id.as_str().to_owned(),
781 node: endpoint.as_str().to_owned(),
782 });
783 }
784 }
785 }
786 let mut freshness_repositories = BTreeSet::new();
787 for record in &context.freshness {
788 if !freshness_repositories.insert(&record.repo_id) {
789 return Err(ImpactError::DuplicateFreshness(
790 record.repo_id.as_str().to_owned(),
791 ));
792 }
793 }
794 for (node_id, centrality) in &context.centrality {
795 validate_node_reference(&nodes, node_id, "centrality")?;
796 if !centrality.is_finite() || !(0.0..=1.0).contains(centrality) {
797 return Err(ImpactError::InvalidCentrality(node_id.as_str().to_owned()));
798 }
799 }
800 for assignment in &context.criticality {
801 validate_node_reference(&nodes, &assignment.node_id, "criticality")?;
802 if assignment.evidence.is_empty()
803 || assignment
804 .evidence
805 .iter()
806 .any(|value| value.trim().is_empty())
807 {
808 return Err(ImpactError::CriticalityWithoutEvidence(
809 assignment.node_id.as_str().to_owned(),
810 ));
811 }
812 }
813 for compatibility in &context.compatibility {
814 validate_node_reference(&nodes, &compatibility.contract_node_id, "compatibility")?;
815 }
816 for node_id in &context.public_contracts {
817 validate_node_reference(&nodes, node_id, "public_contracts")?;
818 }
819 for assignment in &context.environments {
820 validate_node_reference(&nodes, &assignment.node_id, "environments")?;
821 }
822 for (member, services) in &context.service_memberships {
823 validate_node_reference(&nodes, member, "service_memberships")?;
824 for service in services {
825 validate_node_reference(&nodes, service, "service_memberships")?;
826 }
827 }
828 Ok(nodes)
829}
830
831fn validate_node_reference(
832 nodes: &BTreeMap<NodeId, &Node>,
833 node_id: &NodeId,
834 context: &'static str,
835) -> Result<(), ImpactError> {
836 if nodes.contains_key(node_id) {
837 Ok(())
838 } else {
839 Err(ImpactError::UnknownContextNode {
840 context,
841 node: node_id.as_str().to_owned(),
842 })
843 }
844}
845
846fn resolve_target(
847 selector: &ImpactTarget,
848 nodes: &BTreeMap<NodeId, &Node>,
849) -> Result<ResolvedTarget, ImpactError> {
850 match selector {
851 ImpactTarget::NodeId(node_id) => nodes
852 .get(node_id)
853 .map(|node| ResolvedTarget {
854 node: (*node).clone(),
855 resolved_by: "node_id".to_owned(),
856 })
857 .ok_or(ImpactError::UnknownTarget),
858 ImpactTarget::StableKey(stable_key) => {
859 let mut matches = nodes.values().filter(|node| node.stable_key == *stable_key);
860 let Some(node) = matches.next() else {
861 return Err(ImpactError::UnknownTarget);
862 };
863 if matches.next().is_some() {
864 return Err(ImpactError::AmbiguousTarget(stable_key.clone()));
865 }
866 Ok(ResolvedTarget {
867 node: (*node).clone(),
868 resolved_by: "stable_key".to_owned(),
869 })
870 }
871 }
872}
873
874#[expect(
875 clippy::too_many_lines,
876 reason = "The bounded BFS state transitions remain together for auditability"
877)]
878fn traverse(
879 request: &ImpactRequest,
880 context: &ImpactContext,
881 nodes: &BTreeMap<NodeId, &Node>,
882 target: &Node,
883) -> Result<(Vec<ImpactItem>, Option<TruncationInfo>, usize, usize), ImpactError> {
884 let adjacency = build_adjacency(&context.edges, request.direction);
885 let mut queue = VecDeque::from([TraversalState {
886 node_id: target.id.clone(),
887 certainty: PathCertainty::Confirmed,
888 path: Vec::new(),
889 evidence: BTreeSet::new(),
890 }]);
891 let mut best = BTreeMap::<NodeId, (PathCertainty, usize)>::new();
892 best.insert(target.id.clone(), (PathCertainty::Confirmed, 0));
893 let mut items = BTreeMap::<NodeId, ImpactItem>::new();
894 let mut examined_edges = 0_usize;
895 let mut possible_edges = 0_usize;
896 let mut unknown_edges = 0_usize;
897 let mut truncation = None;
898
899 while let Some(state) = queue.pop_front() {
900 let depth = state.path.len();
901 let neighbors = adjacency.get(&state.node_id).map_or(&[][..], Vec::as_slice);
902 if depth == request.options.max_depth {
903 if neighbors.iter().any(|entry| {
904 is_propagating(entry.edge.kind)
905 && !state.path.iter().any(|step| step.from == entry.neighbor)
906 }) {
907 truncation.get_or_insert_with(|| TruncationInfo {
908 bound: "max_depth".to_owned(),
909 limit: request.options.max_depth,
910 observed: depth,
911 explanation: "additional graph relationships exist beyond maximum depth"
912 .to_owned(),
913 });
914 }
915 continue;
916 }
917 for entry in neighbors {
918 if !is_propagating(entry.edge.kind) {
919 continue;
920 }
921 examined_edges = examined_edges.saturating_add(1);
922 if examined_edges > request.options.edge_limit {
923 truncation.get_or_insert_with(|| TruncationInfo {
924 bound: "edge_limit".to_owned(),
925 limit: request.options.edge_limit,
926 observed: examined_edges,
927 explanation: "edge examination limit stopped impact propagation".to_owned(),
928 });
929 break;
930 }
931 if entry.neighbor == target.id
932 || state
933 .path
934 .iter()
935 .any(|step| step.from == entry.neighbor || step.to == entry.neighbor)
936 {
937 continue;
938 }
939 let edge_certainty = edge_certainty(entry.edge, request.options.confirmed_confidence);
940 match edge_certainty {
941 PathCertainty::Confirmed => {}
942 PathCertainty::Possible => possible_edges = possible_edges.saturating_add(1),
943 PathCertainty::Unknown => unknown_edges = unknown_edges.saturating_add(1),
944 }
945 let certainty = state.certainty.max(edge_certainty);
946 let next_depth = depth.saturating_add(1);
947 if best
948 .get(&entry.neighbor)
949 .is_some_and(|existing| *existing <= (certainty, next_depth))
950 {
951 continue;
952 }
953 if !items.contains_key(&entry.neighbor) && items.len() >= request.options.node_limit {
954 truncation.get_or_insert_with(|| TruncationInfo {
955 bound: "node_limit".to_owned(),
956 limit: request.options.node_limit,
957 observed: items.len().saturating_add(1),
958 explanation: "distinct-node limit stopped impact propagation".to_owned(),
959 });
960 break;
961 }
962 let mut path = state.path.clone();
963 path.push(ImpactPathStep {
964 from: state.node_id.clone(),
965 to: entry.neighbor.clone(),
966 edge_id: entry.edge.id.clone(),
967 kind: entry.edge.kind,
968 reversed: entry.reversed,
969 confidence: entry.edge.confidence,
970 status: entry.edge.status,
971 });
972 let mut evidence = state.evidence.clone();
973 evidence.extend(entry.edge.evidence.iter().cloned());
974 let classification = classify(certainty, next_depth);
975 let Some(node) = nodes.get(&entry.neighbor) else {
976 return Err(ImpactError::DanglingEdge {
977 edge: entry.edge.id.as_str().to_owned(),
978 node: entry.neighbor.as_str().to_owned(),
979 });
980 };
981 best.insert(entry.neighbor.clone(), (certainty, next_depth));
982 items.insert(
983 entry.neighbor.clone(),
984 ImpactItem {
985 node: (*node).clone(),
986 classification,
987 depth: next_depth,
988 path: path.clone(),
989 evidence: evidence.iter().cloned().collect(),
990 },
991 );
992 queue.push_back(TraversalState {
993 node_id: entry.neighbor.clone(),
994 certainty,
995 path,
996 evidence,
997 });
998 }
999 if truncation
1000 .as_ref()
1001 .is_some_and(|value| value.bound == "edge_limit" || value.bound == "node_limit")
1002 {
1003 break;
1004 }
1005 }
1006 Ok((
1007 items.into_values().collect(),
1008 truncation,
1009 possible_edges,
1010 unknown_edges,
1011 ))
1012}
1013
1014fn build_adjacency(
1015 edges: &[Edge],
1016 direction: ImpactDirection,
1017) -> BTreeMap<NodeId, Vec<Adjacency<'_>>> {
1018 let mut adjacency = BTreeMap::<NodeId, Vec<Adjacency<'_>>>::new();
1019 for edge in edges {
1020 if matches!(
1021 direction,
1022 ImpactDirection::Downstream | ImpactDirection::Both
1023 ) {
1024 adjacency
1025 .entry(edge.source.clone())
1026 .or_default()
1027 .push(Adjacency {
1028 neighbor: edge.target.clone(),
1029 edge,
1030 reversed: false,
1031 });
1032 }
1033 if matches!(direction, ImpactDirection::Upstream | ImpactDirection::Both) {
1034 adjacency
1035 .entry(edge.target.clone())
1036 .or_default()
1037 .push(Adjacency {
1038 neighbor: edge.source.clone(),
1039 edge,
1040 reversed: true,
1041 });
1042 }
1043 }
1044 for entries in adjacency.values_mut() {
1045 entries.sort_by(|left, right| {
1046 left.neighbor
1047 .cmp(&right.neighbor)
1048 .then_with(|| left.edge.id.cmp(&right.edge.id))
1049 .then_with(|| left.reversed.cmp(&right.reversed))
1050 });
1051 }
1052 adjacency
1053}
1054
1055fn is_propagating(kind: EdgeKind) -> bool {
1056 !matches!(
1057 kind,
1058 EdgeKind::Validates
1059 | EdgeKind::OwnedBy
1060 | EdgeKind::Documents
1061 | EdgeKind::MemberOf
1062 | EdgeKind::Precedes
1063 | EdgeKind::Reverts
1064 | EdgeKind::CompatibleWith
1065 )
1066}
1067
1068fn edge_certainty(edge: &Edge, confirmed_confidence: f32) -> PathCertainty {
1069 match edge.status {
1070 EpistemicStatus::Confirmed if edge.confidence >= confirmed_confidence => {
1071 PathCertainty::Confirmed
1072 }
1073 EpistemicStatus::Confirmed | EpistemicStatus::Inferred | EpistemicStatus::Ambiguous => {
1074 PathCertainty::Possible
1075 }
1076 EpistemicStatus::Stale | EpistemicStatus::Incomplete => PathCertainty::Unknown,
1077 }
1078}
1079
1080fn classify(certainty: PathCertainty, depth: usize) -> ImpactClassification {
1081 match (certainty, depth) {
1082 (PathCertainty::Confirmed, 1) => ImpactClassification::DirectlyDependent,
1083 (PathCertainty::Confirmed, _) => ImpactClassification::TransitivelyAffected,
1084 (PathCertainty::Possible, _) => ImpactClassification::PossiblyAffected,
1085 (PathCertainty::Unknown, _) => ImpactClassification::UnknownDueToCoverage,
1086 }
1087}
1088
1089fn classification_rank(classification: ImpactClassification) -> u8 {
1090 match classification {
1091 ImpactClassification::DirectlyDependent => 0,
1092 ImpactClassification::TransitivelyAffected => 1,
1093 ImpactClassification::PossiblyAffected => 2,
1094 ImpactClassification::UnknownDueToCoverage => 3,
1095 }
1096}
1097
1098fn stronger(left: ImpactClassification, right: ImpactClassification) -> ImpactClassification {
1099 if classification_rank(left) <= classification_rank(right) {
1100 left
1101 } else {
1102 right
1103 }
1104}
1105
1106fn sort_items(items: &mut [ImpactItem]) {
1107 items.sort_by(|left, right| {
1108 classification_rank(left.classification)
1109 .cmp(&classification_rank(right.classification))
1110 .then_with(|| left.depth.cmp(&right.depth))
1111 .then_with(|| left.node.repo_id.cmp(&right.node.repo_id))
1112 .then_with(|| left.node.stable_key.cmp(&right.node.stable_key))
1113 .then_with(|| left.node.id.cmp(&right.node.id))
1114 .then_with(|| path_key(&left.path).cmp(&path_key(&right.path)))
1115 });
1116}
1117
1118fn path_key(path: &[ImpactPathStep]) -> Vec<(&str, bool)> {
1119 path.iter()
1120 .map(|step| (step.edge_id.as_str(), step.reversed))
1121 .collect()
1122}
1123
1124fn aggregate_repositories(items: &[ImpactItem]) -> Vec<RepositoryImpact> {
1125 let mut aggregates = BTreeMap::<RepoId, Aggregate>::new();
1126 for item in items {
1127 let Some(repo_id) = &item.node.repo_id else {
1128 continue;
1129 };
1130 update_aggregate(&mut aggregates, repo_id.clone(), item);
1131 }
1132 aggregates
1133 .into_iter()
1134 .map(|(repo_id, aggregate)| RepositoryImpact {
1135 repo_id,
1136 classification: aggregate.classification,
1137 minimum_depth: aggregate.minimum_depth,
1138 affected_nodes: aggregate.affected_nodes,
1139 })
1140 .collect()
1141}
1142
1143fn aggregate_services(
1144 items: &[ImpactItem],
1145 context: &ImpactContext,
1146 nodes: &BTreeMap<NodeId, &Node>,
1147) -> Vec<ServiceImpact> {
1148 let mut aggregates = BTreeMap::<NodeId, Aggregate>::new();
1149 for item in items {
1150 if item.node.kind == NodeKind::Service {
1151 update_aggregate(&mut aggregates, item.node.id.clone(), item);
1152 }
1153 if let Some(service_ids) = context.service_memberships.get(&item.node.id) {
1154 for service_id in service_ids {
1155 update_aggregate(&mut aggregates, service_id.clone(), item);
1156 }
1157 }
1158 }
1159 aggregates
1160 .into_iter()
1161 .filter_map(|(service_id, aggregate)| {
1162 nodes.get(&service_id).map(|service| ServiceImpact {
1163 service: (*service).clone(),
1164 classification: aggregate.classification,
1165 minimum_depth: aggregate.minimum_depth,
1166 affected_nodes: aggregate.affected_nodes,
1167 })
1168 })
1169 .collect()
1170}
1171
1172fn update_aggregate<K: Ord>(aggregates: &mut BTreeMap<K, Aggregate>, key: K, item: &ImpactItem) {
1173 aggregates
1174 .entry(key)
1175 .and_modify(|aggregate| {
1176 aggregate.classification = stronger(aggregate.classification, item.classification);
1177 aggregate.minimum_depth = aggregate.minimum_depth.min(item.depth);
1178 aggregate.affected_nodes = aggregate.affected_nodes.saturating_add(1);
1179 })
1180 .or_insert(Aggregate {
1181 classification: item.classification,
1182 minimum_depth: item.depth,
1183 affected_nodes: 1,
1184 });
1185}
1186
1187fn aggregate_contracts(items: &[ImpactItem], context: &ImpactContext) -> Vec<ContractImpact> {
1188 let public = context.public_contracts.iter().collect::<BTreeSet<_>>();
1189 items
1190 .iter()
1191 .filter(|item| is_contract(item.node.kind))
1192 .map(|item| ContractImpact {
1193 contract: item.node.clone(),
1194 classification: item.classification,
1195 depth: item.depth,
1196 public: public.contains(&item.node.id),
1197 })
1198 .collect()
1199}
1200
1201fn is_contract(kind: NodeKind) -> bool {
1202 matches!(
1203 kind,
1204 NodeKind::HttpOperation
1205 | NodeKind::GraphqlOperation
1206 | NodeKind::RpcMethod
1207 | NodeKind::EventChannel
1208 | NodeKind::EventSchema
1209 | NodeKind::DatabaseTable
1210 | NodeKind::DatabaseColumn
1211 | NodeKind::ConfigKey
1212 )
1213}
1214
1215fn aggregate_communities(items: &[ImpactItem], context: &ImpactContext) -> Vec<CommunityImpact> {
1216 let Some(snapshot) = &context.communities else {
1217 return Vec::new();
1218 };
1219 let impacted = items
1220 .iter()
1221 .map(|item| (&item.node.id, item))
1222 .collect::<BTreeMap<_, _>>();
1223 let mut result = Vec::new();
1224 for community in &snapshot.communities {
1225 let mut aggregate = None::<Aggregate>;
1226 for member in &community.members {
1227 if let Some(item) = impacted.get(member) {
1228 let current = aggregate.get_or_insert(Aggregate {
1229 classification: item.classification,
1230 minimum_depth: item.depth,
1231 affected_nodes: 0,
1232 });
1233 current.classification = stronger(current.classification, item.classification);
1234 current.minimum_depth = current.minimum_depth.min(item.depth);
1235 current.affected_nodes = current.affected_nodes.saturating_add(1);
1236 }
1237 }
1238 if let Some(aggregate) = aggregate {
1239 result.push(CommunityImpact {
1240 community_id: community.id.clone(),
1241 label: community.label.clone(),
1242 classification: aggregate.classification,
1243 affected_members: aggregate.affected_nodes,
1244 coupling: community.metrics.coupling,
1245 limitations: community.limitations.clone(),
1246 });
1247 }
1248 }
1249 result.sort_by(|left, right| left.community_id.cmp(&right.community_id));
1250 result
1251}
1252
1253fn summarize_local_enrichment(context: &ImpactContext) -> Vec<LocalImpactSummary> {
1254 let mut summaries = context
1255 .local_enrichment
1256 .iter()
1257 .map(|input| LocalImpactSummary {
1258 repo_id: input.repo_id.clone(),
1259 anchor: input.anchor.clone(),
1260 status: input.status,
1261 affected_count: input.affected.len(),
1262 maximum_depth: input
1263 .affected
1264 .iter()
1265 .map(|item| item.depth)
1266 .max()
1267 .unwrap_or(0),
1268 truncated: input.truncated,
1269 degradations: sorted_unique(input.degradations.clone()),
1270 })
1271 .collect::<Vec<_>>();
1272 summaries.sort_by(|left, right| {
1273 left.repo_id
1274 .cmp(&right.repo_id)
1275 .then_with(|| left.anchor.cmp(&right.anchor))
1276 });
1277 summaries
1278}
1279
1280fn recommend_tests(
1281 items: &[ImpactItem],
1282 context: &ImpactContext,
1283 nodes: &BTreeMap<NodeId, &Node>,
1284) -> Vec<TestRecommendation> {
1285 let impacted = items
1286 .iter()
1287 .map(|item| (&item.node.id, item))
1288 .collect::<BTreeMap<_, _>>();
1289 let mut recommendations = Vec::new();
1290 for edge in &context.edges {
1291 if edge.kind != EdgeKind::Validates {
1292 continue;
1293 }
1294 let (test_id, validated_id) = if nodes
1295 .get(&edge.source)
1296 .is_some_and(|node| node.kind == NodeKind::TestCase)
1297 {
1298 (&edge.source, &edge.target)
1299 } else if nodes
1300 .get(&edge.target)
1301 .is_some_and(|node| node.kind == NodeKind::TestCase)
1302 {
1303 (&edge.target, &edge.source)
1304 } else {
1305 continue;
1306 };
1307 let Some(item) = impacted.get(validated_id) else {
1308 continue;
1309 };
1310 let Some(test) = nodes.get(test_id) else {
1311 continue;
1312 };
1313 recommendations.push(TestRecommendation {
1314 test_node_id: Some(test.id.clone()),
1315 test: test.label.clone(),
1316 repo_id: test.repo_id.clone(),
1317 source: TestRecommendationSource::Graph,
1318 rank: 0,
1319 reasons: vec![format!(
1320 "validates impacted node `{}` at depth {}",
1321 item.node.stable_key, item.depth
1322 )],
1323 owners: owners_for(&test.id, validated_id, context, nodes),
1324 recommended_commands: commands_for(test.repo_id.as_ref(), context),
1325 });
1326 }
1327 for local in &context.local_enrichment {
1328 for test in &local.affected_tests {
1329 recommendations.push(TestRecommendation {
1330 test_node_id: None,
1331 test: test.clone(),
1332 repo_id: Some(local.repo_id.clone()),
1333 source: TestRecommendationSource::LocalEnrichment,
1334 rank: 0,
1335 reasons: vec![format!(
1336 "optional local enrichment for anchor `{}` reported this test",
1337 local.anchor
1338 )],
1339 owners: Vec::new(),
1340 recommended_commands: commands_for(Some(&local.repo_id), context),
1341 });
1342 }
1343 }
1344 recommendations.sort_by(|left, right| {
1345 test_source_rank(left.source)
1346 .cmp(&test_source_rank(right.source))
1347 .then_with(|| left.repo_id.cmp(&right.repo_id))
1348 .then_with(|| left.test.cmp(&right.test))
1349 .then_with(|| left.test_node_id.cmp(&right.test_node_id))
1350 });
1351 recommendations.dedup_by(|left, right| {
1352 left.test_node_id == right.test_node_id
1353 && left.repo_id == right.repo_id
1354 && left.test == right.test
1355 });
1356 for (index, recommendation) in recommendations.iter_mut().enumerate() {
1357 recommendation.rank = index.saturating_add(1);
1358 }
1359 recommendations
1360}
1361
1362fn test_source_rank(source: TestRecommendationSource) -> u8 {
1363 match source {
1364 TestRecommendationSource::Graph => 0,
1365 TestRecommendationSource::LocalEnrichment => 1,
1366 }
1367}
1368
1369fn owners_for(
1370 test_id: &NodeId,
1371 validated_id: &NodeId,
1372 context: &ImpactContext,
1373 nodes: &BTreeMap<NodeId, &Node>,
1374) -> Vec<Node> {
1375 let mut owner_ids = BTreeSet::new();
1376 for edge in &context.edges {
1377 if edge.kind == EdgeKind::OwnedBy
1378 && (&edge.source == test_id || &edge.source == validated_id)
1379 && nodes
1380 .get(&edge.target)
1381 .is_some_and(|node| node.kind == NodeKind::Owner)
1382 {
1383 owner_ids.insert(edge.target.clone());
1384 }
1385 }
1386 owner_ids
1387 .iter()
1388 .filter_map(|owner_id| nodes.get(owner_id).map(|node| (*node).clone()))
1389 .collect()
1390}
1391
1392fn commands_for(repo_id: Option<&RepoId>, context: &ImpactContext) -> Vec<String> {
1393 let mut commands = context
1394 .recommended_commands
1395 .iter()
1396 .filter(|command| repo_id.is_some_and(|repo| repo == &command.repo_id))
1397 .map(|command| command.command.clone())
1398 .collect::<Vec<_>>();
1399 commands.sort();
1400 commands.dedup();
1401 commands
1402}
1403
1404fn build_depth_buckets(items: &[ImpactItem]) -> Vec<ImpactDepthBucket> {
1405 let mut buckets = BTreeMap::<usize, ImpactDepthBucket>::new();
1406 for item in items {
1407 let bucket = buckets.entry(item.depth).or_insert(ImpactDepthBucket {
1408 depth: item.depth,
1409 directly_dependent: 0,
1410 transitively_affected: 0,
1411 possibly_affected: 0,
1412 unknown_due_to_coverage: 0,
1413 });
1414 match item.classification {
1415 ImpactClassification::DirectlyDependent => {
1416 bucket.directly_dependent = bucket.directly_dependent.saturating_add(1);
1417 }
1418 ImpactClassification::TransitivelyAffected => {
1419 bucket.transitively_affected = bucket.transitively_affected.saturating_add(1);
1420 }
1421 ImpactClassification::PossiblyAffected => {
1422 bucket.possibly_affected = bucket.possibly_affected.saturating_add(1);
1423 }
1424 ImpactClassification::UnknownDueToCoverage => {
1425 bucket.unknown_due_to_coverage = bucket.unknown_due_to_coverage.saturating_add(1);
1426 }
1427 }
1428 }
1429 buckets.into_values().collect()
1430}
1431
1432#[expect(
1433 clippy::too_many_lines,
1434 reason = "Coverage states and their paired remediations remain visibly exhaustive"
1435)]
1436fn coverage_summary(
1437 target: &Node,
1438 items: &[ImpactItem],
1439 context: &ImpactContext,
1440 truncation: Option<&TruncationInfo>,
1441 possible_edges: usize,
1442 unknown_edges: usize,
1443) -> CoverageSummary {
1444 let mut relevant = items
1445 .iter()
1446 .filter_map(|item| item.node.repo_id.clone())
1447 .collect::<BTreeSet<_>>();
1448 relevant.extend(target.repo_id.iter().cloned());
1449 let freshness = context
1450 .freshness
1451 .iter()
1452 .map(|record| (&record.repo_id, record.state))
1453 .collect::<BTreeMap<_, _>>();
1454 let mut fresh = Vec::new();
1455 let mut stale = Vec::new();
1456 let mut partial = Vec::new();
1457 let mut unavailable = Vec::new();
1458 let mut missing = Vec::new();
1459 for repo_id in &relevant {
1460 match freshness.get(repo_id) {
1461 Some(RepoFreshnessState::Fresh) => fresh.push(repo_id.clone()),
1462 Some(
1463 RepoFreshnessState::WorkingTreeChanged
1464 | RepoFreshnessState::CommitsBehind
1465 | RepoFreshnessState::ConfigChanged
1466 | RepoFreshnessState::ExtractorChanged
1467 | RepoFreshnessState::CodegraphPending,
1468 ) => stale.push(repo_id.clone()),
1469 Some(RepoFreshnessState::Partial) => partial.push(repo_id.clone()),
1470 Some(RepoFreshnessState::Unavailable | RepoFreshnessState::Corrupt) => {
1471 unavailable.push(repo_id.clone());
1472 }
1473 Some(RepoFreshnessState::Unknown) | None => missing.push(repo_id.clone()),
1474 }
1475 }
1476 let mut gaps = context.coverage_gaps.clone();
1477 let mut remediation = Vec::new();
1478 if !context.coverage_gaps.is_empty() {
1479 remediation.push("resolve each caller-supplied coverage gap and rerun analysis".to_owned());
1480 }
1481 if !context.graph_complete {
1482 gaps.push("federated graph extraction or linking is incomplete".to_owned());
1483 remediation.push("complete a fresh workspace scan and relink the graph".to_owned());
1484 }
1485 if !stale.is_empty() {
1486 gaps.push("one or more relevant repositories are stale".to_owned());
1487 remediation.push("rescan stale repositories at their current revisions".to_owned());
1488 }
1489 if !partial.is_empty() {
1490 gaps.push("one or more relevant repositories have partial coverage".to_owned());
1491 remediation.push("resolve extractor limitations and complete partial scans".to_owned());
1492 }
1493 if !unavailable.is_empty() {
1494 gaps.push("one or more relevant repositories are unavailable or corrupt".to_owned());
1495 remediation.push("restore unavailable repositories or valid graph snapshots".to_owned());
1496 }
1497 if !missing.is_empty() {
1498 gaps.push("freshness is missing or unknown for relevant repositories".to_owned());
1499 remediation.push("record current freshness for every relevant repository".to_owned());
1500 }
1501 if possible_edges > 0 {
1502 gaps.push(
1503 "candidate, inferred, ambiguous, or low-confidence relationships were used".to_owned(),
1504 );
1505 remediation.push("corroborate candidate relationships with direct evidence".to_owned());
1506 }
1507 if unknown_edges > 0 {
1508 gaps.push("stale or incomplete relationships were used".to_owned());
1509 remediation
1510 .push("refresh or complete evidence for coverage-unknown relationships".to_owned());
1511 }
1512 let impacted_ids = items
1513 .iter()
1514 .map(|item| &item.node.id)
1515 .chain(std::iter::once(&target.id))
1516 .collect::<BTreeSet<_>>();
1517 let unknown_compatibility = context.compatibility.iter().filter(|input| {
1518 impacted_ids.contains(&input.contract_node_id)
1519 && input.status == ImpactCompatibilityStatus::Unknown
1520 });
1521 let mut compatibility_unknown = false;
1522 for input in unknown_compatibility {
1523 compatibility_unknown = true;
1524 gaps.push(format!(
1525 "compatibility is unknown for contract node `{}`",
1526 input.contract_node_id.as_str()
1527 ));
1528 remediation.extend(input.recommended_validations.iter().cloned());
1529 }
1530 for local in &context.local_enrichment {
1531 if local.status != LocalEnrichmentStatus::Available || local.truncated {
1532 gaps.push(format!(
1533 "local enrichment for repository `{}` is {:?}{}",
1534 local.repo_id.as_str(),
1535 local.status,
1536 if local.truncated {
1537 " and truncated"
1538 } else {
1539 ""
1540 }
1541 ));
1542 remediation.extend(local.degradations.iter().cloned());
1543 }
1544 }
1545 if truncation.is_some() {
1546 gaps.push("configured traversal bounds truncated impact analysis".to_owned());
1547 remediation.push("increase impact bounds or narrow the target scope".to_owned());
1548 }
1549 gaps = sorted_unique(gaps);
1550 remediation = sorted_unique(remediation);
1551 CoverageSummary {
1552 sufficient_for_score: context.graph_complete
1553 && stale.is_empty()
1554 && partial.is_empty()
1555 && unavailable.is_empty()
1556 && missing.is_empty()
1557 && possible_edges == 0
1558 && unknown_edges == 0
1559 && !compatibility_unknown
1560 && truncation.is_none()
1561 && context
1562 .local_enrichment
1563 .iter()
1564 .all(|local| local.status == LocalEnrichmentStatus::Available && !local.truncated)
1565 && context.coverage_gaps.is_empty(),
1566 relevant_repositories: relevant.into_iter().collect(),
1567 fresh_repositories: fresh,
1568 stale_repositories: stale,
1569 partial_repositories: partial,
1570 unavailable_repositories: unavailable,
1571 missing_repositories: missing,
1572 possible_edges,
1573 unknown_edges,
1574 gaps,
1575 remediation,
1576 total_items: items.len(),
1577 }
1578}
1579
1580#[expect(
1581 clippy::too_many_arguments,
1582 clippy::too_many_lines,
1583 reason = "Risk inputs stay explicit to make every scored dimension auditable"
1584)]
1585fn assess_risk(
1586 target: &Node,
1587 items: &[ImpactItem],
1588 repositories: &[RepositoryImpact],
1589 services: &[ServiceImpact],
1590 contracts: &[ContractImpact],
1591 communities: &[CommunityImpact],
1592 tests: &[TestRecommendation],
1593 context: &ImpactContext,
1594 coverage: &CoverageSummary,
1595 truncation: Option<&TruncationInfo>,
1596) -> (RiskLevel, Option<f32>, Vec<RiskFactor>) {
1597 let impacted_ids = items
1598 .iter()
1599 .map(|item| &item.node.id)
1600 .chain(std::iter::once(&target.id))
1601 .collect::<BTreeSet<_>>();
1602 let direct_count = items
1603 .iter()
1604 .filter(|item| item.classification == ImpactClassification::DirectlyDependent)
1605 .count();
1606 let transitive_count = items
1607 .iter()
1608 .filter(|item| item.classification == ImpactClassification::TransitivelyAffected)
1609 .count();
1610 let mut factors = Vec::new();
1611 if direct_count > 0 {
1612 push_factor(
1613 &mut factors,
1614 "direct_consumers",
1615 usize_to_f32(direct_count).mul_add(4.0, 0.0).min(24.0),
1616 format!("{direct_count} confirmed direct dependents"),
1617 direct_evidence(items),
1618 );
1619 }
1620 if transitive_count > 0 {
1621 push_factor(
1622 &mut factors,
1623 "transitive_fanout",
1624 usize_to_f32(transitive_count).mul_add(1.5, 0.0).min(15.0),
1625 format!("{transitive_count} confirmed transitive impacts"),
1626 Vec::new(),
1627 );
1628 }
1629 let mut repository_ids = repositories
1630 .iter()
1631 .map(|impact| impact.repo_id.clone())
1632 .collect::<BTreeSet<_>>();
1633 repository_ids.extend(target.repo_id.iter().cloned());
1634 if repository_ids.len() > 1 {
1635 push_factor(
1636 &mut factors,
1637 "cross_repository_count",
1638 usize_to_f32(repository_ids.len().saturating_sub(1))
1639 .mul_add(4.0, 0.0)
1640 .min(16.0),
1641 format!("impact spans {} repositories", repository_ids.len()),
1642 repository_ids
1643 .iter()
1644 .map(|repo_id| repo_id.as_str().to_owned())
1645 .collect(),
1646 );
1647 }
1648 if !services.is_empty() {
1649 push_factor(
1650 &mut factors,
1651 "service_impact",
1652 usize_to_f32(services.len()).mul_add(2.0, 0.0).min(10.0),
1653 format!("impact reaches {} services", services.len()),
1654 services
1655 .iter()
1656 .map(|impact| impact.service.stable_key.clone())
1657 .collect(),
1658 );
1659 }
1660 let public_contracts = contracts.iter().filter(|contract| contract.public).count()
1661 + usize::from(context.public_contracts.contains(&target.id));
1662 if public_contracts > 0 {
1663 push_factor(
1664 &mut factors,
1665 "public_contract",
1666 12.0,
1667 format!("{public_contracts} explicitly public contracts are involved"),
1668 contracts
1669 .iter()
1670 .filter(|contract| contract.public)
1671 .map(|contract| contract.contract.stable_key.clone())
1672 .collect(),
1673 );
1674 }
1675 add_compatibility_factors(&mut factors, &impacted_ids, context);
1676 if let Some(centrality) = context
1677 .centrality
1678 .get(&target.id)
1679 .filter(|value| **value >= 0.75)
1680 {
1681 push_factor(
1682 &mut factors,
1683 "centrality",
1684 *centrality * 12.0,
1685 format!("target centrality is {centrality:.3}"),
1686 vec![target.id.as_str().to_owned()],
1687 );
1688 }
1689 if !communities.is_empty() {
1690 let cross_edges = communities
1691 .iter()
1692 .filter(|community| community.coupling > 0.0)
1693 .count();
1694 if cross_edges > 0 {
1695 push_factor(
1696 &mut factors,
1697 "community_process_fanout",
1698 usize_to_f32(cross_edges).mul_add(3.0, 0.0).min(9.0),
1699 format!("{cross_edges} affected communities cross structural boundaries"),
1700 communities
1701 .iter()
1702 .map(|community| community.community_id.as_str().to_owned())
1703 .collect(),
1704 );
1705 }
1706 }
1707 add_criticality_factors(&mut factors, &impacted_ids, context);
1708 if tests.is_empty() && !items.is_empty() {
1709 push_factor(
1710 &mut factors,
1711 "missing_tests",
1712 10.0,
1713 "no linked or locally supplied affected tests were found".to_owned(),
1714 Vec::new(),
1715 );
1716 }
1717 let owners = owner_count(&impacted_ids, context);
1718 if owners == 0 && !items.is_empty() {
1719 push_factor(
1720 &mut factors,
1721 "missing_owners",
1722 8.0,
1723 "no Owner node is linked by OwnedBy to an impacted node".to_owned(),
1724 Vec::new(),
1725 );
1726 }
1727 let environments = context
1728 .environments
1729 .iter()
1730 .filter(|assignment| impacted_ids.contains(&assignment.node_id))
1731 .map(|assignment| assignment.environment.as_str())
1732 .collect::<BTreeSet<_>>();
1733 if environments.len() > 1 {
1734 push_factor(
1735 &mut factors,
1736 "cross_environment",
1737 10.0,
1738 format!("impact spans {} explicit environments", environments.len()),
1739 environments.into_iter().map(str::to_owned).collect(),
1740 );
1741 }
1742 if !coverage.sufficient_for_score {
1743 push_factor(
1744 &mut factors,
1745 "coverage_unknown",
1746 0.0,
1747 "coverage is insufficient for a numeric risk conclusion".to_owned(),
1748 coverage.gaps.clone(),
1749 );
1750 }
1751 if let Some(info) = truncation {
1752 push_factor(
1753 &mut factors,
1754 "truncated",
1755 0.0,
1756 info.explanation.clone(),
1757 vec![format!("{}={}", info.bound, info.limit)],
1758 );
1759 }
1760 factors.sort_by(|left, right| {
1761 right
1762 .weight
1763 .partial_cmp(&left.weight)
1764 .unwrap_or(Ordering::Equal)
1765 .then_with(|| left.code.cmp(&right.code))
1766 .then_with(|| left.explanation.cmp(&right.explanation))
1767 });
1768 if !coverage.sufficient_for_score {
1769 return (RiskLevel::Unknown, None, factors);
1770 }
1771 let score = factors
1772 .iter()
1773 .map(|factor| factor.weight)
1774 .sum::<f32>()
1775 .clamp(1.0, 100.0);
1776 let explicit_critical = factors.iter().any(|factor| {
1777 matches!(
1778 factor.code.as_str(),
1779 "critical_tag" | "security_tag" | "payment_tag" | "data_boundary_tag"
1780 ) && !factor.evidence.is_empty()
1781 });
1782 let level = if score >= 85.0 && explicit_critical {
1783 RiskLevel::Critical
1784 } else if score >= 50.0 {
1785 RiskLevel::High
1786 } else if score >= 25.0 {
1787 RiskLevel::Medium
1788 } else {
1789 RiskLevel::Low
1790 };
1791 (level, Some(score), factors)
1792}
1793
1794fn add_compatibility_factors(
1795 factors: &mut Vec<RiskFactor>,
1796 impacted_ids: &BTreeSet<&NodeId>,
1797 context: &ImpactContext,
1798) {
1799 for input in &context.compatibility {
1800 if !impacted_ids.contains(&input.contract_node_id) {
1801 continue;
1802 }
1803 match input.status {
1804 ImpactCompatibilityStatus::Breaking => push_factor(
1805 factors,
1806 "breaking_compatibility",
1807 30.0,
1808 "a compatibility engine reported a breaking contract change".to_owned(),
1809 input.evidence.clone(),
1810 ),
1811 ImpactCompatibilityStatus::PotentiallyBreaking => push_factor(
1812 factors,
1813 "potentially_breaking_compatibility",
1814 18.0,
1815 "a compatibility engine reported a potentially breaking change".to_owned(),
1816 input.evidence.clone(),
1817 ),
1818 ImpactCompatibilityStatus::Compatible => {}
1819 ImpactCompatibilityStatus::Unknown => push_factor(
1820 factors,
1821 "compatibility_unknown",
1822 0.0,
1823 "compatibility coverage is unknown".to_owned(),
1824 input
1825 .evidence
1826 .iter()
1827 .chain(input.recommended_validations.iter())
1828 .cloned()
1829 .collect(),
1830 ),
1831 }
1832 }
1833}
1834
1835fn add_criticality_factors(
1836 factors: &mut Vec<RiskFactor>,
1837 impacted_ids: &BTreeSet<&NodeId>,
1838 context: &ImpactContext,
1839) {
1840 for assignment in &context.criticality {
1841 if !impacted_ids.contains(&assignment.node_id) {
1842 continue;
1843 }
1844 let (code, weight) = match assignment.tag {
1845 CriticalityTag::Critical => ("critical_tag", 55.0),
1846 CriticalityTag::Authentication => ("authentication_tag", 35.0),
1847 CriticalityTag::Security => ("security_tag", 50.0),
1848 CriticalityTag::Payment => ("payment_tag", 50.0),
1849 CriticalityTag::DataBoundary => ("data_boundary_tag", 45.0),
1850 };
1851 push_factor(
1852 factors,
1853 code,
1854 weight,
1855 format!(
1856 "explicit {:?} tag applies to impacted node `{}`",
1857 assignment.tag,
1858 assignment.node_id.as_str()
1859 ),
1860 assignment.evidence.clone(),
1861 );
1862 }
1863}
1864
1865fn owner_count(impacted_ids: &BTreeSet<&NodeId>, context: &ImpactContext) -> usize {
1866 context
1867 .edges
1868 .iter()
1869 .filter(|edge| edge.kind == EdgeKind::OwnedBy && impacted_ids.contains(&edge.source))
1870 .map(|edge| &edge.target)
1871 .collect::<BTreeSet<_>>()
1872 .len()
1873}
1874
1875fn direct_evidence(items: &[ImpactItem]) -> Vec<String> {
1876 let mut evidence = items
1877 .iter()
1878 .filter(|item| item.classification == ImpactClassification::DirectlyDependent)
1879 .flat_map(|item| item.evidence.iter().map(|id| id.as_str().to_owned()))
1880 .collect::<Vec<_>>();
1881 evidence.sort();
1882 evidence.dedup();
1883 evidence
1884}
1885
1886fn push_factor(
1887 factors: &mut Vec<RiskFactor>,
1888 code: &str,
1889 weight: f32,
1890 explanation: String,
1891 evidence: Vec<String>,
1892) {
1893 factors.push(RiskFactor {
1894 code: code.to_owned(),
1895 weight,
1896 explanation,
1897 evidence: sorted_unique(evidence),
1898 });
1899}
1900
1901fn usize_to_f32(value: usize) -> f32 {
1902 u16::try_from(value).map_or(f32::from(u16::MAX), f32::from)
1903}
1904
1905fn split_classifications(
1906 items: Vec<ImpactItem>,
1907) -> (
1908 Vec<ImpactItem>,
1909 Vec<ImpactItem>,
1910 Vec<ImpactItem>,
1911 Vec<ImpactItem>,
1912) {
1913 let mut direct = Vec::new();
1914 let mut transitive = Vec::new();
1915 let mut possible = Vec::new();
1916 let mut unknown = Vec::new();
1917 for item in items {
1918 match item.classification {
1919 ImpactClassification::DirectlyDependent => direct.push(item),
1920 ImpactClassification::TransitivelyAffected => transitive.push(item),
1921 ImpactClassification::PossiblyAffected => possible.push(item),
1922 ImpactClassification::UnknownDueToCoverage => unknown.push(item),
1923 }
1924 }
1925 (direct, transitive, possible, unknown)
1926}
1927
1928fn sorted_unique(mut values: Vec<String>) -> Vec<String> {
1929 values.sort();
1930 values.dedup();
1931 values
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936 use code_system_graph_model::{CheckoutId, Community, CommunityConfig, CommunityMetrics};
1937
1938 use super::*;
1939
1940 fn node(id: &str, kind: NodeKind, repo: &str) -> Node {
1941 Node {
1942 id: NodeId::new(id),
1943 kind,
1944 repo_id: Some(RepoId::new(repo)),
1945 stable_key: format!("{repo}:{id}"),
1946 label: id.to_owned(),
1947 }
1948 }
1949
1950 fn edge(id: &str, source: &str, target: &str) -> Edge {
1951 Edge {
1952 id: EdgeId::new(id),
1953 source: NodeId::new(source),
1954 target: NodeId::new(target),
1955 kind: EdgeKind::Consumes,
1956 confidence: 1.0,
1957 status: EpistemicStatus::Confirmed,
1958 evidence: vec![EvidenceId::new(format!("e-{id}"))],
1959 }
1960 }
1961
1962 fn freshness(repo: &str, state: RepoFreshnessState) -> RepoFreshness {
1963 RepoFreshness {
1964 repo_id: RepoId::new(repo),
1965 checkout_id: CheckoutId::new(format!("checkout-{repo}")),
1966 head_commit: Some("abc".to_owned()),
1967 manifest_hash: "manifest".to_owned(),
1968 state,
1969 reason: None,
1970 }
1971 }
1972
1973 fn context(nodes: Vec<Node>, edges: Vec<Edge>) -> ImpactContext {
1974 let repos = nodes
1975 .iter()
1976 .filter_map(|item| item.repo_id.clone())
1977 .collect::<BTreeSet<_>>();
1978 ImpactContext {
1979 nodes,
1980 edges,
1981 communities: None,
1982 freshness: repos
1983 .iter()
1984 .map(|repo| freshness(repo.as_str(), RepoFreshnessState::Fresh))
1985 .collect(),
1986 compatibility: Vec::new(),
1987 local_enrichment: Vec::new(),
1988 public_contracts: Vec::new(),
1989 criticality: Vec::new(),
1990 centrality: BTreeMap::new(),
1991 service_memberships: BTreeMap::new(),
1992 environments: Vec::new(),
1993 recommended_commands: Vec::new(),
1994 graph_complete: true,
1995 coverage_gaps: Vec::new(),
1996 }
1997 }
1998
1999 fn request(target: &str, direction: ImpactDirection) -> ImpactRequest {
2000 ImpactRequest {
2001 target: ImpactTarget::NodeId(NodeId::new(target)),
2002 direction,
2003 options: ImpactOptions::default(),
2004 }
2005 }
2006
2007 fn analyze(context: &ImpactContext, direction: ImpactDirection) -> ImpactReport {
2008 analyze_impact(&request("target", direction), context).expect("analysis should succeed")
2009 }
2010
2011 #[test]
2012 fn breaking_direct_impact_should_raise_high_risk() {
2013 let mut context = context(
2014 vec![
2015 node("consumer", NodeKind::Service, "a"),
2016 node("target", NodeKind::HttpOperation, "b"),
2017 ],
2018 vec![edge("direct", "consumer", "target")],
2019 );
2020 context.compatibility.push(CompatibilityInput {
2021 contract_node_id: NodeId::new("target"),
2022 status: ImpactCompatibilityStatus::Breaking,
2023 evidence: vec!["http.required_parameter_added".to_owned()],
2024 recommended_validations: Vec::new(),
2025 });
2026 context.public_contracts.push(NodeId::new("target"));
2027
2028 let report = analyze(&context, ImpactDirection::Upstream);
2029
2030 assert_eq!(report.risk, RiskLevel::High);
2031 }
2032
2033 #[test]
2034 fn breaking_transitive_path_should_remain_confirmed() {
2035 let context = context(
2036 vec![
2037 node("far", NodeKind::Service, "a"),
2038 node("near", NodeKind::Service, "b"),
2039 node("target", NodeKind::HttpOperation, "c"),
2040 ],
2041 vec![edge("one", "near", "target"), edge("two", "far", "near")],
2042 );
2043
2044 let report = analyze(&context, ImpactDirection::Upstream);
2045
2046 assert_eq!(report.transitive_consumers[0].node.id.as_str(), "far");
2047 }
2048
2049 #[test]
2050 fn stale_freshness_should_prevent_false_safe_score() {
2051 let mut context = context(
2052 vec![
2053 node("consumer", NodeKind::Service, "a"),
2054 node("target", NodeKind::HttpOperation, "b"),
2055 ],
2056 vec![edge("direct", "consumer", "target")],
2057 );
2058 context.freshness[0].state = RepoFreshnessState::WorkingTreeChanged;
2059
2060 let report = analyze(&context, ImpactDirection::Upstream);
2061
2062 assert_eq!((report.risk, report.risk_score), (RiskLevel::Unknown, None));
2063 }
2064
2065 #[test]
2066 fn low_confidence_should_be_unknown_risk_not_low_impact() {
2067 let mut candidate = edge("candidate", "consumer", "target");
2068 candidate.confidence = 0.2;
2069 let context = context(
2070 vec![
2071 node("consumer", NodeKind::Service, "a"),
2072 node("target", NodeKind::HttpOperation, "b"),
2073 ],
2074 vec![candidate],
2075 );
2076
2077 let report = analyze(&context, ImpactDirection::Upstream);
2078
2079 assert_eq!(
2080 (report.risk, report.possibly_affected[0].classification),
2081 (RiskLevel::Unknown, ImpactClassification::PossiblyAffected)
2082 );
2083 }
2084
2085 #[test]
2086 fn depth_truncation_should_force_unknown() {
2087 let context = context(
2088 vec![
2089 node("three", NodeKind::Service, "a"),
2090 node("two", NodeKind::Service, "b"),
2091 node("one", NodeKind::Service, "c"),
2092 node("target", NodeKind::HttpOperation, "d"),
2093 ],
2094 vec![
2095 edge("one", "one", "target"),
2096 edge("two", "two", "one"),
2097 edge("three", "three", "two"),
2098 ],
2099 );
2100 let mut request = request("target", ImpactDirection::Upstream);
2101 request.options.max_depth = 2;
2102
2103 let report = analyze_impact(&request, &context).expect("analysis should succeed");
2104
2105 assert_eq!(report.risk, RiskLevel::Unknown);
2106 }
2107
2108 #[test]
2109 fn direction_should_select_incoming_or_outgoing_edges() {
2110 let context = context(
2111 vec![
2112 node("upstream", NodeKind::Service, "a"),
2113 node("target", NodeKind::HttpOperation, "b"),
2114 node("downstream", NodeKind::Service, "c"),
2115 ],
2116 vec![
2117 edge("incoming", "upstream", "target"),
2118 edge("outgoing", "target", "downstream"),
2119 ],
2120 );
2121
2122 let report = analyze(&context, ImpactDirection::Downstream);
2123
2124 assert_eq!(report.direct_consumers[0].node.id.as_str(), "downstream");
2125 }
2126
2127 #[test]
2128 fn both_direction_should_include_both_sides() {
2129 let context = context(
2130 vec![
2131 node("upstream", NodeKind::Service, "a"),
2132 node("target", NodeKind::HttpOperation, "b"),
2133 node("downstream", NodeKind::Service, "c"),
2134 ],
2135 vec![
2136 edge("incoming", "upstream", "target"),
2137 edge("outgoing", "target", "downstream"),
2138 ],
2139 );
2140
2141 let report = analyze(&context, ImpactDirection::Both);
2142
2143 assert_eq!(report.coverage.total_items, 2);
2144 }
2145
2146 #[test]
2147 fn cycles_should_terminate_without_repeating_target() {
2148 let context = context(
2149 vec![
2150 node("a", NodeKind::Service, "a"),
2151 node("target", NodeKind::Service, "b"),
2152 ],
2153 vec![edge("one", "target", "a"), edge("two", "a", "target")],
2154 );
2155
2156 let report = analyze(&context, ImpactDirection::Downstream);
2157
2158 assert_eq!(report.coverage.total_items, 1);
2159 }
2160
2161 #[test]
2162 fn pagination_should_use_stable_combined_order() {
2163 let context = context(
2164 vec![
2165 node("a", NodeKind::Service, "a"),
2166 node("b", NodeKind::Service, "b"),
2167 node("target", NodeKind::Service, "t"),
2168 ],
2169 vec![edge("a", "a", "target"), edge("b", "b", "target")],
2170 );
2171 let mut request = request("target", ImpactDirection::Upstream);
2172 request.options.offset = 1;
2173 request.options.limit = 1;
2174
2175 let report = analyze_impact(&request, &context).expect("analysis should succeed");
2176
2177 assert_eq!(report.direct_consumers[0].node.id.as_str(), "b");
2178 }
2179
2180 #[test]
2181 fn summary_only_should_omit_items_and_retain_counts() {
2182 let context = context(
2183 vec![
2184 node("consumer", NodeKind::Service, "a"),
2185 node("target", NodeKind::Service, "b"),
2186 ],
2187 vec![edge("direct", "consumer", "target")],
2188 );
2189 let mut request = request("target", ImpactDirection::Upstream);
2190 request.options.summary_only = true;
2191
2192 let report = analyze_impact(&request, &context).expect("analysis should succeed");
2193
2194 assert_eq!(
2195 (report.direct_consumers.len(), report.coverage.total_items),
2196 (0, 1)
2197 );
2198 }
2199
2200 #[test]
2201 fn graph_tests_and_owners_should_be_ranked() {
2202 let mut validates = edge("validates", "test", "consumer");
2203 validates.kind = EdgeKind::Validates;
2204 let mut owned = edge("owned", "test", "owner");
2205 owned.kind = EdgeKind::OwnedBy;
2206 let context = context(
2207 vec![
2208 node("test", NodeKind::TestCase, "a"),
2209 node("owner", NodeKind::Owner, "a"),
2210 node("consumer", NodeKind::Service, "a"),
2211 node("target", NodeKind::HttpOperation, "b"),
2212 ],
2213 vec![edge("direct", "consumer", "target"), validates, owned],
2214 );
2215
2216 let report = analyze(&context, ImpactDirection::Upstream);
2217
2218 assert_eq!(
2219 report.test_recommendations[0].owners[0].id.as_str(),
2220 "owner"
2221 );
2222 }
2223
2224 #[test]
2225 fn explicit_commands_should_only_be_returned_not_executed() {
2226 let mut validates = edge("validates", "test", "consumer");
2227 validates.kind = EdgeKind::Validates;
2228 let mut context = context(
2229 vec![
2230 node("test", NodeKind::TestCase, "a"),
2231 node("consumer", NodeKind::Service, "a"),
2232 node("target", NodeKind::Service, "b"),
2233 ],
2234 vec![edge("direct", "consumer", "target"), validates],
2235 );
2236 context.recommended_commands.push(RecommendedCommand {
2237 repo_id: RepoId::new("a"),
2238 command: "cargo test -p consumer".to_owned(),
2239 description: "consumer tests".to_owned(),
2240 });
2241
2242 let report = analyze(&context, ImpactDirection::Upstream);
2243
2244 assert_eq!(
2245 report.test_recommendations[0].recommended_commands,
2246 vec!["cargo test -p consumer"]
2247 );
2248 }
2249
2250 #[test]
2251 fn affected_community_should_include_coupling() {
2252 let mut context = context(
2253 vec![
2254 node("consumer", NodeKind::Service, "a"),
2255 node("target", NodeKind::Service, "b"),
2256 ],
2257 vec![edge("direct", "consumer", "target")],
2258 );
2259 context.communities = Some(CommunitySnapshot {
2260 snapshot_id: "snapshot".to_owned(),
2261 engine_version: "1.0.0".to_owned(),
2262 config: CommunityConfig {
2263 algorithm: code_system_graph_model::CommunityAlgorithm::ConnectedComponents,
2264 scope: code_system_graph_model::CommunityScope::Federated,
2265 seed: 0,
2266 resolution: 1.0,
2267 minimum_confidence: 0.8,
2268 edge_weights: Vec::new(),
2269 max_iterations: 1,
2270 },
2271 communities: vec![Community {
2272 id: CommunityId::new("community"),
2273 label: "orders".to_owned(),
2274 members: vec![NodeId::new("consumer")],
2275 central_nodes: Vec::new(),
2276 repositories: vec![RepoId::new("a")],
2277 services: vec![NodeId::new("consumer")],
2278 inbound_contracts: Vec::new(),
2279 outbound_contracts: Vec::new(),
2280 metrics: CommunityMetrics {
2281 size: 1,
2282 density: 0.0,
2283 cohesion: 0.0,
2284 coupling: 2.0,
2285 cross_community_edges: 1,
2286 },
2287 label_evidence: Vec::new(),
2288 limitations: Vec::new(),
2289 }],
2290 });
2291
2292 let report = analyze(&context, ImpactDirection::Upstream);
2293
2294 assert!((report.affected_communities[0].coupling - 2.0).abs() < f64::EPSILON);
2295 }
2296
2297 #[test]
2298 fn local_enrichment_degradation_should_be_visible_and_unknown() {
2299 let mut context = context(
2300 vec![
2301 node("consumer", NodeKind::Service, "a"),
2302 node("target", NodeKind::Service, "b"),
2303 ],
2304 vec![edge("direct", "consumer", "target")],
2305 );
2306 context.local_enrichment.push(LocalEnrichmentInput {
2307 repo_id: RepoId::new("a"),
2308 anchor: "consumer".to_owned(),
2309 status: LocalEnrichmentStatus::Stale,
2310 affected: Vec::new(),
2311 affected_tests: Vec::new(),
2312 truncated: false,
2313 degradations: vec!["reindex repository".to_owned()],
2314 });
2315
2316 let report = analyze(&context, ImpactDirection::Upstream);
2317
2318 assert_eq!(
2319 (report.risk, report.local_impact_summaries[0].status),
2320 (RiskLevel::Unknown, LocalEnrichmentStatus::Stale)
2321 );
2322 }
2323
2324 #[test]
2325 fn explicit_security_tag_with_evidence_can_produce_critical() {
2326 let mut context = context(
2327 vec![
2328 node("consumer", NodeKind::Service, "a"),
2329 node("target", NodeKind::HttpOperation, "b"),
2330 ],
2331 vec![edge("direct", "consumer", "target")],
2332 );
2333 context.criticality.push(CriticalityAssignment {
2334 node_id: NodeId::new("target"),
2335 tag: CriticalityTag::Security,
2336 evidence: vec!["policy:security-boundary".to_owned()],
2337 });
2338 context.public_contracts.push(NodeId::new("target"));
2339 context.compatibility.push(CompatibilityInput {
2340 contract_node_id: NodeId::new("target"),
2341 status: ImpactCompatibilityStatus::Breaking,
2342 evidence: vec!["breaking".to_owned()],
2343 recommended_validations: Vec::new(),
2344 });
2345
2346 let report = analyze(&context, ImpactDirection::Upstream);
2347
2348 assert_eq!(report.risk, RiskLevel::Critical);
2349 }
2350
2351 #[test]
2352 fn labels_should_not_infer_critical_tags() {
2353 let context = context(
2354 vec![
2355 node("payment-security", NodeKind::Service, "a"),
2356 node("target", NodeKind::Service, "b"),
2357 ],
2358 vec![edge("direct", "payment-security", "target")],
2359 );
2360
2361 let report = analyze(&context, ImpactDirection::Upstream);
2362
2363 assert!(
2364 !report
2365 .reasons
2366 .iter()
2367 .any(|factor| factor.code.ends_with("_tag"))
2368 );
2369 }
2370
2371 #[test]
2372 fn identical_inputs_should_produce_identical_reports() {
2373 let context = context(
2374 vec![
2375 node("b", NodeKind::Service, "b"),
2376 node("target", NodeKind::Service, "t"),
2377 node("a", NodeKind::Service, "a"),
2378 ],
2379 vec![edge("b", "b", "target"), edge("a", "a", "target")],
2380 );
2381
2382 let first = analyze(&context, ImpactDirection::Upstream);
2383 let second = analyze(&context, ImpactDirection::Upstream);
2384
2385 assert_eq!(first, second);
2386 }
2387
2388 #[test]
2389 fn invalid_bounds_should_be_rejected() {
2390 let context = context(vec![node("target", NodeKind::Service, "a")], Vec::new());
2391 let mut request = request("target", ImpactDirection::Both);
2392 request.options.max_depth = 0;
2393
2394 let error = analyze_impact(&request, &context).expect_err("zero depth must be rejected");
2395
2396 assert_eq!(error, ImpactError::InvalidBounds);
2397 }
2398
2399 #[test]
2400 fn invalid_edge_confidence_should_be_rejected() {
2401 let mut invalid = edge("invalid", "consumer", "target");
2402 invalid.confidence = f32::NAN;
2403 let context = context(
2404 vec![
2405 node("consumer", NodeKind::Service, "a"),
2406 node("target", NodeKind::Service, "b"),
2407 ],
2408 vec![invalid],
2409 );
2410
2411 let error = analyze_impact(&request("target", ImpactDirection::Upstream), &context)
2412 .expect_err("NaN confidence must be rejected");
2413
2414 assert_eq!(
2415 error,
2416 ImpactError::InvalidEdgeConfidence("invalid".to_owned())
2417 );
2418 }
2419
2420 #[test]
2421 fn fresh_complete_empty_impact_should_have_positive_low_score() {
2422 let context = context(vec![node("target", NodeKind::Service, "a")], Vec::new());
2423
2424 let report = analyze(&context, ImpactDirection::Both);
2425
2426 assert_eq!(
2427 (report.risk, report.risk_score),
2428 (RiskLevel::Low, Some(1.0))
2429 );
2430 }
2431}