Skip to main content

code_system_graph_core/
impact.rs

1//! Deterministic, conservative cross-repository impact and risk analysis.
2
3use 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/// Conservative risk classification for an impact result.
21#[derive(
22    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
23)]
24#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
25pub enum RiskLevel {
26    /// Fresh, complete coverage found only bounded low-risk factors.
27    Low,
28    /// Material but bounded impact was found.
29    Medium,
30    /// Broad, breaking, or otherwise severe impact was found.
31    High,
32    /// Explicit critical-domain evidence and a sufficiently high score were found.
33    Critical,
34    /// Coverage is insufficient for a numeric risk conclusion.
35    Unknown,
36}
37
38/// Direction in which impact propagates from the resolved target.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum ImpactDirection {
42    /// Follow incoming relationships to dependents and consumers.
43    Upstream,
44    /// Follow outgoing relationships to dependencies and providers.
45    Downstream,
46    /// Follow both incoming and outgoing relationships.
47    Both,
48}
49
50/// Confidence-aware classification of one affected graph entity.
51#[derive(
52    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
53)]
54#[serde(rename_all = "snake_case")]
55pub enum ImpactClassification {
56    /// A confirmed high-confidence edge directly links the entity to the target.
57    DirectlyDependent,
58    /// A confirmed high-confidence path transitively links the entity to the target.
59    TransitivelyAffected,
60    /// Candidate, inferred, ambiguous, or low-confidence evidence may link the entity.
61    PossiblyAffected,
62    /// Stale or incomplete evidence prevents a stronger classification.
63    UnknownDueToCoverage,
64}
65
66/// Selector used to resolve exactly one graph target.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
69pub enum ImpactTarget {
70    /// Resolve an exact graph node identifier.
71    NodeId(NodeId),
72    /// Resolve an exact versioned stable key.
73    StableKey(String),
74}
75
76/// Exact graph target selected for analysis.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
78pub struct ResolvedTarget {
79    /// Resolved graph node.
80    pub node: Node,
81    /// Canonical selector form used to resolve the node.
82    pub resolved_by: String,
83}
84
85/// One directed relationship in an impact path.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
87pub struct ImpactPathStep {
88    /// Node from which this traversal step began.
89    pub from: NodeId,
90    /// Node reached by this traversal step.
91    pub to: NodeId,
92    /// Existing graph relationship used by this step.
93    pub edge_id: EdgeId,
94    /// Relationship kind.
95    pub kind: EdgeKind,
96    /// Whether the underlying edge was followed from target to source.
97    pub reversed: bool,
98    /// Edge confidence as supplied by the graph.
99    pub confidence: f32,
100    /// Edge epistemic status as supplied by the graph.
101    pub status: EpistemicStatus,
102}
103
104/// One deterministically selected affected graph entity.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
106pub struct ImpactItem {
107    /// Affected node.
108    pub node: Node,
109    /// Conservative impact classification.
110    pub classification: ImpactClassification,
111    /// Number of graph relationships from the target.
112    pub depth: usize,
113    /// Deterministic shortest and strongest path selected for this node.
114    pub path: Vec<ImpactPathStep>,
115    /// Sorted evidence identifiers supporting the selected path.
116    pub evidence: Vec<EvidenceId>,
117}
118
119/// One explainable component of the versioned risk model.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
121pub struct RiskFactor {
122    /// Stable machine-readable factor code.
123    pub code: String,
124    /// Non-negative score contribution before the final 100-point cap.
125    pub weight: f32,
126    /// Bounded human-readable explanation.
127    pub explanation: String,
128    /// Sorted evidence identifiers or explicit context references.
129    pub evidence: Vec<String>,
130}
131
132/// Aggregate impact for one repository.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134pub struct RepositoryImpact {
135    /// Affected repository.
136    pub repo_id: RepoId,
137    /// Strongest impact classification observed in the repository.
138    pub classification: ImpactClassification,
139    /// Minimum path depth among affected nodes.
140    pub minimum_depth: usize,
141    /// Number of affected graph nodes before pagination.
142    pub affected_nodes: usize,
143}
144
145/// Aggregate impact for one service.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
147pub struct ServiceImpact {
148    /// Service node.
149    pub service: Node,
150    /// Strongest impact classification observed for service members.
151    pub classification: ImpactClassification,
152    /// Minimum path depth among affected service members.
153    pub minimum_depth: usize,
154    /// Number of affected service members.
155    pub affected_nodes: usize,
156}
157
158/// Impact on a modeled public or private contract.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160pub struct ContractImpact {
161    /// Affected contract node.
162    pub contract: Node,
163    /// Conservative impact classification.
164    pub classification: ImpactClassification,
165    /// Minimum path depth.
166    pub depth: usize,
167    /// Whether the contract was explicitly declared public in the analysis context.
168    pub public: bool,
169}
170
171/// Aggregate impact for one detected graph community.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
173pub struct CommunityImpact {
174    /// Community identity.
175    pub community_id: CommunityId,
176    /// Deterministic community label.
177    pub label: String,
178    /// Strongest member impact classification.
179    pub classification: ImpactClassification,
180    /// Number of affected community members.
181    pub affected_members: usize,
182    /// Accepted edge weight crossing the community boundary.
183    pub coupling: f64,
184    /// Explicit community limitations copied from the immutable snapshot.
185    pub limitations: Vec<String>,
186}
187
188/// Availability state of optional repository-local enrichment.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190#[serde(rename_all = "snake_case")]
191pub enum LocalEnrichmentStatus {
192    /// The local provider completed with current inputs.
193    Available,
194    /// The local provider returned only part of the requested result.
195    Partial,
196    /// The local index does not match current repository inputs.
197    Stale,
198    /// The local capability or repository index is unavailable.
199    Unavailable,
200}
201
202/// One repository-local symbol supplied as optional input data.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204pub struct LocalImpactItem {
205    /// Provider-local symbol name.
206    pub symbol: String,
207    /// Repository-relative source path.
208    pub file_path: String,
209    /// One-based source start line when available.
210    pub start_line: Option<usize>,
211    /// Local traversal depth.
212    pub depth: usize,
213}
214
215/// Optional local impact input produced before this pure analysis call.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
217pub struct LocalEnrichmentInput {
218    /// Repository to which this local result belongs.
219    pub repo_id: RepoId,
220    /// Exact local anchor requested from the provider.
221    pub anchor: String,
222    /// Provider/index availability.
223    pub status: LocalEnrichmentStatus,
224    /// Bounded affected local symbols.
225    pub affected: Vec<LocalImpactItem>,
226    /// Bounded affected test paths.
227    pub affected_tests: Vec<String>,
228    /// Whether provider or adapter bounds truncated this local result.
229    pub truncated: bool,
230    /// Explicit provider degradations and remediation.
231    pub degradations: Vec<String>,
232}
233
234/// Visible repository-local enrichment included in the report.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct LocalImpactSummary {
237    /// Repository to which the local result belongs.
238    pub repo_id: RepoId,
239    /// Exact local anchor requested from the provider.
240    pub anchor: String,
241    /// Provider/index availability.
242    pub status: LocalEnrichmentStatus,
243    /// Number of local symbols returned.
244    pub affected_count: usize,
245    /// Maximum local depth observed.
246    pub maximum_depth: usize,
247    /// Whether the local result was truncated.
248    pub truncated: bool,
249    /// Explicit provider degradations and remediation.
250    pub degradations: Vec<String>,
251}
252
253/// Origin of a recommended test.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255#[serde(rename_all = "snake_case")]
256pub enum TestRecommendationSource {
257    /// A graph `TestCase` linked by `Validates`.
258    Graph,
259    /// An affected-test result supplied by optional local enrichment.
260    LocalEnrichment,
261}
262
263/// Ranked, non-executing recommendation for impact validation.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
265pub struct TestRecommendation {
266    /// Stable graph node when the recommendation came from a modeled test case.
267    pub test_node_id: Option<NodeId>,
268    /// Test label or repository-relative test path.
269    pub test: String,
270    /// Repository containing the test when known.
271    pub repo_id: Option<RepoId>,
272    /// Recommendation origin.
273    pub source: TestRecommendationSource,
274    /// One-based deterministic rank.
275    pub rank: usize,
276    /// Reasons this test is relevant.
277    pub reasons: Vec<String>,
278    /// Owner nodes linked to the test or validated impact target.
279    pub owners: Vec<Node>,
280    /// Explicit commands supplied by context; these are never executed.
281    pub recommended_commands: Vec<String>,
282}
283
284/// Coverage details that control whether a numeric risk score is permitted.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286pub struct CoverageSummary {
287    /// Whether all required graph, freshness, and enrichment inputs are sufficient.
288    pub sufficient_for_score: bool,
289    /// Relevant repositories discovered from the target and affected nodes.
290    pub relevant_repositories: Vec<RepoId>,
291    /// Relevant repositories with fresh inputs.
292    pub fresh_repositories: Vec<RepoId>,
293    /// Relevant repositories with stale or changed inputs.
294    pub stale_repositories: Vec<RepoId>,
295    /// Relevant repositories with partial inputs.
296    pub partial_repositories: Vec<RepoId>,
297    /// Relevant repositories whose checkout or data is unavailable.
298    pub unavailable_repositories: Vec<RepoId>,
299    /// Relevant repositories without a freshness record.
300    pub missing_repositories: Vec<RepoId>,
301    /// Number of candidate or low-confidence edges selected by traversal.
302    pub possible_edges: usize,
303    /// Number of stale or incomplete edges selected by traversal.
304    pub unknown_edges: usize,
305    /// Explicit coverage limitations in deterministic order.
306    pub gaps: Vec<String>,
307    /// Concrete remediation guidance in deterministic order.
308    pub remediation: Vec<String>,
309    /// Total affected items before pagination.
310    pub total_items: usize,
311}
312
313/// Configured bound that stopped complete traversal or result delivery.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
315pub struct TruncationInfo {
316    /// Stable machine-readable bound name.
317    pub bound: String,
318    /// Configured bound value.
319    pub limit: usize,
320    /// Number observed when the bound stopped analysis.
321    pub observed: usize,
322    /// Explanation of the uncertainty introduced by truncation.
323    pub explanation: String,
324}
325
326/// Counts of impact classifications at one exact path depth.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328pub struct ImpactDepthBucket {
329    /// Exact path depth represented by this bucket.
330    pub depth: usize,
331    /// Directly dependent item count.
332    pub directly_dependent: usize,
333    /// Transitively affected item count.
334    pub transitively_affected: usize,
335    /// Possibly affected item count.
336    pub possibly_affected: usize,
337    /// Coverage-unknown item count.
338    pub unknown_due_to_coverage: usize,
339}
340
341/// Deterministic bounds and presentation controls for impact analysis.
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
343pub struct ImpactOptions {
344    /// Maximum graph path depth.
345    #[serde(default = "default_impact_max_depth")]
346    pub max_depth: usize,
347    /// Maximum number of distinct affected nodes.
348    #[serde(default = "default_impact_node_limit")]
349    pub node_limit: usize,
350    /// Maximum number of graph edges examined.
351    #[serde(default = "default_impact_edge_limit")]
352    pub edge_limit: usize,
353    /// Minimum confidence required for a confirmed edge.
354    #[serde(default = "default_confirmed_confidence")]
355    pub confirmed_confidence: f32,
356    /// Zero-based offset over the stable combined impact order.
357    #[serde(default)]
358    pub offset: usize,
359    /// Maximum number of detailed items returned.
360    #[serde(default = "default_impact_limit")]
361    pub limit: usize,
362    /// Omit detailed impact-item lists while retaining aggregates and counts.
363    #[serde(default)]
364    pub summary_only: bool,
365    /// Include exact-depth classification buckets.
366    #[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/// Request for one deterministic impact analysis.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
411pub struct ImpactRequest {
412    /// Exact node identifier or stable key selector.
413    pub target: ImpactTarget,
414    /// Requested propagation direction.
415    pub direction: ImpactDirection,
416    /// Traversal and presentation controls.
417    #[serde(default)]
418    pub options: ImpactOptions,
419}
420
421/// Compatibility classification supplied to the impact engine.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
423#[serde(rename_all = "snake_case")]
424pub enum ImpactCompatibilityStatus {
425    /// A supported compatibility rule proves a breaking change.
426    Breaking,
427    /// The change can be breaking but requires runtime or policy confirmation.
428    PotentiallyBreaking,
429    /// Modeled rules prove compatibility under complete inputs.
430    Compatible,
431    /// Compatibility coverage is insufficient.
432    Unknown,
433}
434
435/// Compatibility result associated with a graph contract.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
437pub struct CompatibilityInput {
438    /// Contract node to which this result applies.
439    pub contract_node_id: NodeId,
440    /// Conservative compatibility classification.
441    pub status: ImpactCompatibilityStatus,
442    /// Stable rule codes or bounded evidence references.
443    pub evidence: Vec<String>,
444    /// Recommended compatibility validations.
445    pub recommended_validations: Vec<String>,
446}
447
448/// Explicit critical-domain tag; labels are never interpreted as tags.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
450#[serde(rename_all = "snake_case")]
451pub enum CriticalityTag {
452    /// Explicitly designated critical system or process.
453    Critical,
454    /// Authentication or authorization boundary.
455    Authentication,
456    /// Security-sensitive boundary.
457    Security,
458    /// Payment or financial boundary.
459    Payment,
460    /// Data governance, privacy, or storage boundary.
461    DataBoundary,
462}
463
464/// Explicit, evidence-backed critical-domain assignment.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
466pub struct CriticalityAssignment {
467    /// Tagged graph node.
468    pub node_id: NodeId,
469    /// Explicit critical-domain tag.
470    pub tag: CriticalityTag,
471    /// Non-empty evidence or policy reference supplied by the caller.
472    pub evidence: Vec<String>,
473}
474
475/// Explicit deployment environment associated with a graph node.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
477pub struct EnvironmentAssignment {
478    /// Deployment, service, contract, or repository node.
479    pub node_id: NodeId,
480    /// Canonical environment name supplied by configuration.
481    pub environment: String,
482}
483
484/// Explicit non-executing validation command supplied by configuration.
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
486pub struct RecommendedCommand {
487    /// Repository in which the command is valid.
488    pub repo_id: RepoId,
489    /// Command displayed to the caller but never executed by this module.
490    pub command: String,
491    /// Bounded explanation of the command's purpose.
492    pub description: String,
493}
494
495/// Complete immutable input context for pure impact analysis.
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
497pub struct ImpactContext {
498    /// Federated graph nodes.
499    pub nodes: Vec<Node>,
500    /// Federated graph relationships.
501    pub edges: Vec<Edge>,
502    /// Optional immutable community analysis for this graph snapshot.
503    pub communities: Option<CommunitySnapshot>,
504    /// Repository freshness records.
505    pub freshness: Vec<RepoFreshness>,
506    /// Compatibility results computed before this call.
507    pub compatibility: Vec<CompatibilityInput>,
508    /// Optional repository-local impact and affected-test inputs.
509    pub local_enrichment: Vec<LocalEnrichmentInput>,
510    /// Nodes explicitly declared to be public contracts.
511    pub public_contracts: Vec<NodeId>,
512    /// Explicit critical-domain assignments.
513    pub criticality: Vec<CriticalityAssignment>,
514    /// Normalized centrality scores keyed by graph node.
515    pub centrality: BTreeMap<NodeId, f32>,
516    /// Service memberships keyed by member node.
517    pub service_memberships: BTreeMap<NodeId, Vec<NodeId>>,
518    /// Explicit deployment environment assignments.
519    pub environments: Vec<EnvironmentAssignment>,
520    /// Explicit non-executing validation commands.
521    pub recommended_commands: Vec<RecommendedCommand>,
522    /// Whether graph extraction and linking completed for the requested workspace.
523    pub graph_complete: bool,
524    /// Additional known coverage gaps.
525    pub coverage_gaps: Vec<String>,
526}
527
528/// Complete deterministic impact and risk result.
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
530pub struct ImpactReport {
531    /// Version of the deterministic risk model.
532    pub risk_model_version: String,
533    /// Exact target selected for analysis.
534    pub target: ResolvedTarget,
535    /// Applied propagation direction.
536    pub direction: ImpactDirection,
537    /// Conservative aggregate risk.
538    pub risk: RiskLevel,
539    /// Positive score in `0..=100`, omitted whenever risk is unknown.
540    pub risk_score: Option<f32>,
541    /// Deterministically ordered risk factors.
542    pub reasons: Vec<RiskFactor>,
543    /// Confirmed depth-one dependents in the selected page.
544    pub direct_consumers: Vec<ImpactItem>,
545    /// Confirmed depth-two-or-greater impacts in the selected page.
546    pub transitive_consumers: Vec<ImpactItem>,
547    /// Candidate, inferred, ambiguous, or low-confidence impacts in the selected page.
548    pub possibly_affected: Vec<ImpactItem>,
549    /// Impacts whose selected path contains stale or incomplete evidence.
550    pub unknown_due_to_coverage: Vec<ImpactItem>,
551    /// Repository aggregates computed before pagination.
552    pub affected_repositories: Vec<RepositoryImpact>,
553    /// Service aggregates computed before pagination.
554    pub affected_services: Vec<ServiceImpact>,
555    /// Contract impacts computed before pagination.
556    pub affected_contracts: Vec<ContractImpact>,
557    /// Community aggregates computed before pagination.
558    pub affected_communities: Vec<CommunityImpact>,
559    /// Optional local-enrichment summaries.
560    pub local_impact_summaries: Vec<LocalImpactSummary>,
561    /// Ranked test recommendations.
562    pub test_recommendations: Vec<TestRecommendation>,
563    /// Optional exact-depth counts computed before pagination.
564    pub depth_buckets: Vec<ImpactDepthBucket>,
565    /// Coverage and remediation details.
566    pub coverage: CoverageSummary,
567    /// First configured bound that introduced uncertainty.
568    pub truncation: Option<TruncationInfo>,
569}
570
571/// Validation error returned before impact analysis begins.
572#[derive(Debug, Error, PartialEq)]
573pub enum ImpactError {
574    /// More than one graph node has the same identifier.
575    #[error("duplicate node identifier `{0}`")]
576    DuplicateNode(String),
577    /// More than one graph relationship has the same identifier.
578    #[error("duplicate edge identifier `{0}`")]
579    DuplicateEdge(String),
580    /// More than one freshness record exists for the same repository.
581    #[error("duplicate freshness record for repository `{0}`")]
582    DuplicateFreshness(String),
583    /// A graph relationship references a missing endpoint.
584    #[error("edge `{edge}` references missing node `{node}`")]
585    DanglingEdge {
586        /// Relationship identifier.
587        edge: String,
588        /// Missing endpoint identifier.
589        node: String,
590    },
591    /// An input edge has non-finite or out-of-range confidence.
592    #[error("edge `{0}` confidence must be finite and in the inclusive range 0..=1")]
593    InvalidEdgeConfidence(String),
594    /// The configured confirmed-confidence threshold is invalid.
595    #[error("confirmed confidence must be finite and in the inclusive range 0..=1")]
596    InvalidConfirmedConfidence,
597    /// One or more traversal or pagination bounds are invalid.
598    #[error("impact traversal or pagination bounds are outside supported limits")]
599    InvalidBounds,
600    /// A supplied centrality score is non-finite or out of range.
601    #[error("centrality for node `{0}` must be finite and in the inclusive range 0..=1")]
602    InvalidCentrality(String),
603    /// An explicit criticality assignment has no evidence.
604    #[error("criticality assignment for node `{0}` requires explicit evidence")]
605    CriticalityWithoutEvidence(String),
606    /// An input context references a node absent from the graph.
607    #[error("{context} references missing node `{node}`")]
608    UnknownContextNode {
609        /// Context collection containing the reference.
610        context: &'static str,
611        /// Missing node identifier.
612        node: String,
613    },
614    /// No graph node matches the requested target.
615    #[error("impact target was not found")]
616    UnknownTarget,
617    /// A stable-key selector resolves to more than one node.
618    #[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/// Performs pure deterministic impact propagation, aggregation, and conservative risk analysis.
652///
653/// The function never invokes providers or executes commands. Optional compatibility, local
654/// intelligence, environment, criticality, and command data must already be present in `context`.
655///
656/// # Errors
657///
658/// Returns [`ImpactError`] when graph identities, references, confidence values, centrality
659/// scores, criticality evidence, target resolution, or configured bounds are invalid.
660#[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}