Skip to main content

vela_protocol/
impact.rs

1//! Read-only dependency impact reports.
2
3use std::collections::{BTreeSet, VecDeque};
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::events;
9use crate::project::Project;
10use crate::repo;
11
12pub const IMPACT_SCHEMA: &str = "vela.impact_report.v0.1";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct ImpactTarget {
16    pub r#type: String,
17    pub id: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct ImpactFrontier {
22    pub vfr_id: String,
23    pub snapshot_hash: String,
24    pub event_log_hash: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ImpactSummary {
29    pub direct_dependents: usize,
30    pub total_downstream: usize,
31    pub open_proposals: usize,
32    pub accepted_events: usize,
33    pub proof_status: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct ImpactDownstream {
38    pub finding_id: String,
39    pub depth: usize,
40    pub via_link_type: String,
41    pub via_finding_id: String,
42    pub cross_frontier: bool,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct ImpactProposal {
47    pub id: String,
48    pub kind: String,
49    pub status: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct ImpactEvent {
54    pub id: String,
55    pub kind: String,
56    pub reason: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct ImpactReport {
61    pub schema: String,
62    pub target: ImpactTarget,
63    pub frontier: ImpactFrontier,
64    pub summary: ImpactSummary,
65    #[serde(default)]
66    pub downstream: Vec<ImpactDownstream>,
67    #[serde(default)]
68    pub proposals: Vec<ImpactProposal>,
69    #[serde(default)]
70    pub events: Vec<ImpactEvent>,
71    #[serde(default)]
72    pub caveats: Vec<String>,
73}
74
75pub fn analyze_path(
76    path: &Path,
77    finding_id: &str,
78    depth: Option<usize>,
79) -> Result<ImpactReport, String> {
80    let frontier = repo::load_from_path(path)?;
81    analyze(&frontier, finding_id, depth)
82}
83
84pub fn analyze(
85    frontier: &Project,
86    finding_id: &str,
87    depth: Option<usize>,
88) -> Result<ImpactReport, String> {
89    if !frontier
90        .findings
91        .iter()
92        .any(|finding| finding.id == finding_id)
93    {
94        return Err(format!("finding not found: {finding_id}"));
95    }
96    let max_depth = depth.unwrap_or(3).max(1);
97    let downstream = downstream(frontier, finding_id, max_depth);
98    let proposals = frontier
99        .proposals
100        .iter()
101        .filter(|proposal| proposal.target.r#type == "finding" && proposal.target.id == finding_id)
102        .map(|proposal| ImpactProposal {
103            id: proposal.id.clone(),
104            kind: proposal.kind.clone(),
105            status: proposal.status.clone(),
106        })
107        .collect::<Vec<_>>();
108    let events = frontier
109        .events
110        .iter()
111        .filter(|event| event.target.r#type == "finding" && event.target.id == finding_id)
112        .map(|event| ImpactEvent {
113            id: event.id.clone(),
114            kind: event.kind.clone(),
115            reason: event.reason.clone(),
116        })
117        .collect::<Vec<_>>();
118    let direct_dependents = downstream.iter().filter(|item| item.depth == 1).count();
119    let open_proposals = proposals
120        .iter()
121        .filter(|proposal| {
122            proposal.status == "pending_review" || proposal.status == "needs_revision"
123        })
124        .count();
125    let accepted_events = events.len();
126    Ok(ImpactReport {
127        schema: IMPACT_SCHEMA.to_string(),
128        target: ImpactTarget {
129            r#type: "finding".to_string(),
130            id: finding_id.to_string(),
131        },
132        frontier: ImpactFrontier {
133            vfr_id: frontier
134                .frontier_id
135                .clone()
136                .unwrap_or_else(|| "unknown".to_string()),
137            snapshot_hash: events::snapshot_hash(frontier),
138            event_log_hash: events::event_log_hash(&frontier.events),
139        },
140        summary: ImpactSummary {
141            direct_dependents,
142            total_downstream: downstream.len(),
143            open_proposals,
144            accepted_events,
145            proof_status: proof_status(frontier),
146        },
147        downstream,
148        proposals,
149        events,
150        caveats: vec![
151            "Impact is a read-only dependency report over declared links, not automatic confidence propagation.".to_string(),
152        ],
153    })
154}
155
156fn downstream(frontier: &Project, finding_id: &str, max_depth: usize) -> Vec<ImpactDownstream> {
157    let mut out = Vec::new();
158    let mut seen = BTreeSet::<String>::new();
159    let mut queue = VecDeque::from([(finding_id.to_string(), 0usize)]);
160    while let Some((target, depth)) = queue.pop_front() {
161        if depth >= max_depth {
162            continue;
163        }
164        for finding in &frontier.findings {
165            for link in &finding.links {
166                if !matches!(
167                    link.link_type.as_str(),
168                    "supports" | "depends" | "contradicts"
169                ) {
170                    continue;
171                }
172                if link_target_matches(&link.target, &target) && seen.insert(finding.id.clone()) {
173                    let next_depth = depth + 1;
174                    out.push(ImpactDownstream {
175                        finding_id: finding.id.clone(),
176                        depth: next_depth,
177                        via_link_type: link.link_type.clone(),
178                        via_finding_id: target.clone(),
179                        cross_frontier: link.target.contains("@vfr_"),
180                    });
181                    queue.push_back((finding.id.clone(), next_depth));
182                }
183            }
184        }
185    }
186    out.sort_by(|a, b| a.depth.cmp(&b.depth).then(a.finding_id.cmp(&b.finding_id)));
187    out
188}
189
190fn link_target_matches(link_target: &str, target: &str) -> bool {
191    link_target == target
192        || link_target
193            .split_once('@')
194            .is_some_and(|(id, _)| id == target)
195}
196
197fn proof_status(frontier: &Project) -> String {
198    let status = frontier.proof_state.latest_packet.status.as_str();
199    match status {
200        "current" => "fresh".to_string(),
201        "stale" => "stale".to_string(),
202        _ => "unknown".to_string(),
203    }
204}