Skip to main content

blast_radius/
graph.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use clap::ValueEnum;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct AnalysisResult {
9    pub mode: AnalysisMode,
10    pub target: AnalysisTarget,
11    pub repo_root: PathBuf,
12    pub source_file_count: usize,
13    pub summary: Summary,
14    pub workspaces: Vec<Workspace>,
15    /// Per-input-file impact, populated only for multi-file runs.
16    pub roots: Vec<RootImpact>,
17    pub nodes: Vec<GraphNode>,
18    pub edges: Vec<GraphEdge>,
19    pub warnings: Vec<String>,
20}
21
22/// A workspace package discovered in the repo, used to group impacted files by
23/// the package they live in.
24#[derive(Debug, Clone, Serialize)]
25pub struct Workspace {
26    pub name: String,
27    /// Package root, relative to the repo root (empty string == repo root).
28    pub root: String,
29}
30
31/// The blast radius of a single input file, so a multi-file run can show each
32/// file's impact individually alongside the combined total.
33#[derive(Debug, Clone, Serialize)]
34pub struct RootImpact {
35    pub file: String,
36    pub affected: usize,
37    pub direct: usize,
38    pub indirect: usize,
39    pub max_depth: usize,
40    pub packages: usize,
41    pub files: Vec<RootImpactFile>,
42}
43
44/// A single file impacted by a particular input file.
45#[derive(Debug, Clone, Serialize)]
46pub struct RootImpactFile {
47    pub path: String,
48    pub endpoint: bool,
49}
50
51/// Map a repo-relative path to the package that owns it: the longest matching
52/// workspace root, falling back to the top-level directory. `workspaces` is
53/// expected to be sorted longest-root-first.
54pub fn package_key(rel_path: &str, workspaces: &[Workspace]) -> String {
55    if let Some(workspace) = workspaces.iter().find(|workspace| {
56        workspace.root.is_empty()
57            || rel_path == workspace.root
58            || rel_path.starts_with(&format!("{}/", workspace.root))
59    }) {
60        if workspace.root.is_empty() {
61            ".".to_string()
62        } else {
63            workspace.root.clone()
64        }
65    } else {
66        match rel_path.split_once('/') {
67            Some((head, _)) => head.to_string(),
68            None => ".".to_string(),
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum AnalysisMode {
76    Export,
77    File,
78    Files,
79}
80
81#[derive(Debug, Clone, Serialize)]
82#[serde(tag = "kind", rename_all = "snake_case")]
83pub enum AnalysisTarget {
84    Export { file: PathBuf, export_name: String },
85    File { file: PathBuf },
86    Files { files: Vec<PathBuf> },
87}
88
89#[derive(Debug, Clone, Serialize, Default)]
90pub struct Summary {
91    pub directly_affected_files: usize,
92    pub transitively_affected_files: usize,
93    pub total_affected_files: usize,
94    pub unresolved_imports: usize,
95    pub ambiguous_edges: usize,
96    pub parse_failures: usize,
97    /// Input paths passed to `files` mode that were skipped because they were
98    /// missing on disk or not recognized source files.
99    pub skipped_inputs: usize,
100    /// The headline risk verdict for this run, derived from reach and spread.
101    pub risk_tier: RiskTier,
102}
103
104/// The headline blast-radius verdict. Ordered least-to-most severe so callers
105/// can gate on `tier >= threshold` (see `--fail-on-risk`).
106#[derive(
107    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum,
108)]
109#[serde(rename_all = "snake_case")]
110pub enum RiskTier {
111    #[default]
112    Minor,
113    Moderate,
114    Risky,
115    High,
116}
117
118/// Reach and spread drive the tier; ambiguity is surfaced as a confidence
119/// caveat elsewhere rather than inflating the score, so the headline stays
120/// trustworthy. `affected` counts downstream files (excludes the target);
121/// `packages` is the number of distinct packages they span.
122pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
123    if affected == 0 {
124        RiskTier::Minor
125    } else if affected > 25 || packages >= 3 {
126        RiskTier::High
127    } else if affected <= 3 && packages <= 1 {
128        RiskTier::Minor
129    } else if affected <= 10 && packages <= 2 {
130        RiskTier::Moderate
131    } else {
132        RiskTier::Risky
133    }
134}
135
136#[derive(Debug, Clone, Serialize)]
137pub struct GraphNode {
138    pub id: String,
139    pub label: String,
140    pub file: PathBuf,
141    pub symbol: Option<String>,
142    pub kind: NodeKind,
143    pub depth: usize,
144}
145
146#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum NodeKind {
149    File,
150    Export,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct GraphEdge {
155    pub from: String,
156    pub to: String,
157    pub kind: EdgeKind,
158    pub is_ambiguous: bool,
159}
160
161#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
162#[serde(rename_all = "snake_case")]
163pub enum EdgeKind {
164    ImportsNamed,
165    ImportsDefault,
166    ImportsNamespace,
167    ReexportsNamed,
168    ReexportsStar,
169    UsesJsxComponent,
170    RequiresModule,
171    CommonJsExport,
172}
173
174#[derive(Debug, Clone)]
175pub struct ModuleState {
176    pub file: PathBuf,
177    pub public_exports: BTreeSet<String>,
178    pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
179    pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
180}
181
182impl ModuleState {
183    pub fn new(file: PathBuf) -> Self {
184        Self {
185            file,
186            public_exports: BTreeSet::new(),
187            export_to_locals: BTreeMap::new(),
188            local_to_exports: BTreeMap::new(),
189        }
190    }
191
192    pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
193        let exported = exported.into();
194        self.public_exports.insert(exported.clone());
195        if let Some(local) = local {
196            self.export_to_locals
197                .entry(exported.clone())
198                .or_default()
199                .insert(local.clone());
200            self.local_to_exports
201                .entry(local)
202                .or_default()
203                .insert(exported);
204        }
205    }
206}