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/// Version of the JSON output schema. Bump on breaking changes to the
8/// serialized shape (renamed/removed fields, changed meanings); additive
9/// fields do not bump it.
10pub const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct AnalysisResult {
14    pub schema_version: u32,
15    pub mode: AnalysisMode,
16    pub target: AnalysisTarget,
17    pub repo_root: PathBuf,
18    pub source_file_count: usize,
19    pub summary: Summary,
20    pub workspaces: Vec<Workspace>,
21    /// Per-input-file impact, populated only for multi-file runs.
22    pub roots: Vec<RootImpact>,
23    pub nodes: Vec<GraphNode>,
24    pub edges: Vec<GraphEdge>,
25    pub warnings: Vec<String>,
26}
27
28/// A workspace package discovered in the repo, used to group impacted files by
29/// the package they live in.
30#[derive(Debug, Clone, Serialize)]
31pub struct Workspace {
32    pub name: String,
33    /// Package root, relative to the repo root (empty string == repo root).
34    pub root: String,
35}
36
37/// The blast radius of a single input file, so a multi-file run can show each
38/// file's impact individually alongside the combined total.
39#[derive(Debug, Clone, Serialize)]
40pub struct RootImpact {
41    pub file: String,
42    pub affected: usize,
43    pub direct: usize,
44    pub indirect: usize,
45    pub max_depth: usize,
46    pub packages: usize,
47    pub files: Vec<RootImpactFile>,
48}
49
50/// A single file impacted by a particular input file.
51#[derive(Debug, Clone, Serialize)]
52pub struct RootImpactFile {
53    pub path: String,
54    pub endpoint: bool,
55    /// Hops from the changed file (1 == direct consumer).
56    pub depth: usize,
57}
58
59/// Normalize the separators in a path label to `/`. Labels are built from
60/// `Path::display()`, which uses `\` on Windows, while every grouping rule in
61/// this module and the reports splits on `/` — without this, on Windows every
62/// file collapses into package `.` and the package count degrades to 1.
63pub fn normalize_separators(label: impl Into<String>) -> String {
64    let label = label.into();
65    if label.contains('\\') {
66        label.replace('\\', "/")
67    } else {
68        label
69    }
70}
71
72/// Map a repo-relative path to the package that owns it: the longest matching
73/// workspace root, falling back to the top-level directory. `workspaces` is
74/// expected to be sorted longest-root-first. Both the path and the workspace
75/// roots are separator-normalized defensively, so Windows-style labels still
76/// group correctly.
77pub fn package_key(rel_path: &str, workspaces: &[Workspace]) -> String {
78    let rel_path = normalize_separators(rel_path);
79    if let Some(root) = workspaces.iter().find_map(|workspace| {
80        let root = normalize_separators(workspace.root.as_str());
81        (root.is_empty() || rel_path == root || rel_path.starts_with(&format!("{root}/")))
82            .then_some(root)
83    }) {
84        if root.is_empty() {
85            ".".to_string()
86        } else {
87            root
88        }
89    } else {
90        match rel_path.split_once('/') {
91            Some((head, _)) => head.to_string(),
92            None => ".".to_string(),
93        }
94    }
95}
96
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "snake_case")]
99pub enum AnalysisMode {
100    Export,
101    File,
102    Files,
103}
104
105#[derive(Debug, Clone, Serialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum AnalysisTarget {
108    Export { file: PathBuf, export_name: String },
109    File { file: PathBuf },
110    Files { files: Vec<PathBuf> },
111}
112
113#[derive(Debug, Clone, Serialize, Default)]
114pub struct Summary {
115    pub directly_affected_files: usize,
116    pub transitively_affected_files: usize,
117    pub total_affected_files: usize,
118    pub unresolved_imports: usize,
119    pub ambiguous_edges: usize,
120    pub parse_failures: usize,
121    /// Input paths passed to `files` mode that were skipped because they were
122    /// missing on disk or not recognized source files.
123    pub skipped_inputs: usize,
124    /// The headline risk verdict for this run, derived from reach and spread.
125    pub risk_tier: RiskTier,
126}
127
128/// The headline blast-radius verdict. Ordered least-to-most severe so callers
129/// can gate on `tier >= threshold` (see `--fail-on-risk`).
130#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
131#[serde(rename_all = "snake_case")]
132pub enum RiskTier {
133    #[default]
134    Minor,
135    Moderate,
136    Risky,
137    High,
138}
139
140/// Reach and spread drive the tier; ambiguity is surfaced as a confidence
141/// caveat elsewhere rather than inflating the score, so the headline stays
142/// trustworthy. `affected` counts downstream files (excludes the target);
143/// `packages` is the number of distinct packages they span.
144pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
145    if affected == 0 {
146        RiskTier::Minor
147    } else if affected > 25 || packages >= 3 {
148        RiskTier::High
149    } else if affected <= 3 && packages <= 1 {
150        RiskTier::Minor
151    } else if affected <= 10 && packages <= 2 {
152        RiskTier::Moderate
153    } else {
154        RiskTier::Risky
155    }
156}
157
158#[derive(Debug, Clone, Serialize)]
159pub struct GraphNode {
160    pub id: String,
161    pub label: String,
162    pub file: PathBuf,
163    pub symbol: Option<String>,
164    pub kind: NodeKind,
165    pub depth: usize,
166}
167
168#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum NodeKind {
171    File,
172    Export,
173}
174
175#[derive(Debug, Clone, Serialize)]
176pub struct GraphEdge {
177    pub from: String,
178    pub to: String,
179    pub kind: EdgeKind,
180    pub is_ambiguous: bool,
181}
182
183#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
184#[serde(rename_all = "snake_case")]
185pub enum EdgeKind {
186    ImportsNamed,
187    ImportsDefault,
188    ImportsNamespace,
189    ImportsDynamic,
190    ReexportsNamed,
191    ReexportsStar,
192    UsesJsxComponent,
193    RequiresModule,
194    CommonJsExport,
195}
196
197#[derive(Debug, Clone)]
198pub struct ModuleState {
199    pub file: PathBuf,
200    pub public_exports: BTreeSet<String>,
201    pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
202    pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
203}
204
205impl ModuleState {
206    pub fn new(file: PathBuf) -> Self {
207        Self {
208            file,
209            public_exports: BTreeSet::new(),
210            export_to_locals: BTreeMap::new(),
211            local_to_exports: BTreeMap::new(),
212        }
213    }
214
215    pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
216        let exported = exported.into();
217        self.public_exports.insert(exported.clone());
218        if let Some(local) = local {
219            self.export_to_locals
220                .entry(exported.clone())
221                .or_default()
222                .insert(local.clone());
223            self.local_to_exports
224                .entry(local)
225                .or_default()
226                .insert(exported);
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{Workspace, normalize_separators, package_key};
234
235    fn workspace(name: &str, root: &str) -> Workspace {
236        Workspace {
237            name: name.to_string(),
238            root: root.to_string(),
239        }
240    }
241
242    #[test]
243    fn normalize_separators_converts_backslashes() {
244        assert_eq!(
245            normalize_separators("src\\report\\tree.rs"),
246            "src/report/tree.rs"
247        );
248        assert_eq!(
249            normalize_separators("src/report/tree.rs"),
250            "src/report/tree.rs"
251        );
252        assert_eq!(normalize_separators(""), "");
253    }
254
255    #[test]
256    fn package_key_matches_workspace_with_backslash_path() {
257        let workspaces = vec![workspace("ui", "packages/ui"), workspace("repo", "")];
258        assert_eq!(
259            package_key("packages\\ui\\src\\button.tsx", &workspaces),
260            "packages/ui"
261        );
262        // Exact match on the workspace root itself.
263        assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
264    }
265
266    #[test]
267    fn package_key_handles_backslash_workspace_roots() {
268        // Workspace roots are labels too, so normalize them defensively as well.
269        let workspaces = vec![workspace("ui", "packages\\ui")];
270        assert_eq!(
271            package_key("packages/ui/src/button.tsx", &workspaces),
272            "packages/ui"
273        );
274        assert_eq!(
275            package_key("packages\\ui\\src\\button.tsx", &workspaces),
276            "packages/ui"
277        );
278    }
279
280    #[test]
281    fn package_key_falls_back_to_top_level_directory() {
282        assert_eq!(package_key("src\\lib\\util.rs", &[]), "src");
283        assert_eq!(package_key("src/lib/util.rs", &[]), "src");
284        assert_eq!(package_key("main.rs", &[]), ".");
285    }
286
287    #[test]
288    fn package_key_does_not_match_sibling_prefix() {
289        // "packages/ui-kit" must not match the "packages/ui" workspace.
290        let workspaces = vec![workspace("ui", "packages/ui")];
291        assert_eq!(
292            package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
293            "packages"
294        );
295    }
296}