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    /// The whole-repo import graph dump (`graph` command), not an impact query.
104    Graph,
105}
106
107#[derive(Debug, Clone, Serialize)]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum AnalysisTarget {
110    Export {
111        file: PathBuf,
112        export_name: String,
113    },
114    File {
115        file: PathBuf,
116    },
117    Files {
118        files: Vec<PathBuf>,
119    },
120    /// Whole-repo dump; no single target. `summary`/`risk_tier` are not
121    /// meaningful in this mode — read `nodes`/`edges`.
122    Graph,
123}
124
125#[derive(Debug, Clone, Serialize, Default)]
126pub struct Summary {
127    pub directly_affected_files: usize,
128    pub transitively_affected_files: usize,
129    pub total_affected_files: usize,
130    pub unresolved_imports: usize,
131    pub ambiguous_edges: usize,
132    pub parse_failures: usize,
133    /// Input paths passed to `files` mode that were skipped because they were
134    /// missing on disk or not recognized source files.
135    pub skipped_inputs: usize,
136    /// The headline risk verdict for this run, derived from reach and spread.
137    pub risk_tier: RiskTier,
138}
139
140/// The headline blast-radius verdict. Ordered least-to-most severe so callers
141/// can gate on `tier >= threshold` (see `--fail-on-risk`).
142#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
143#[serde(rename_all = "snake_case")]
144pub enum RiskTier {
145    #[default]
146    Minor,
147    Moderate,
148    Risky,
149    High,
150}
151
152/// Reach and spread drive the tier; ambiguity is surfaced as a confidence
153/// caveat elsewhere rather than inflating the score, so the headline stays
154/// trustworthy. `affected` counts downstream files (excludes the target);
155/// `packages` is the number of distinct packages they span.
156pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
157    if affected == 0 {
158        RiskTier::Minor
159    } else if affected > 25 || packages >= 3 {
160        RiskTier::High
161    } else if affected <= 3 && packages <= 1 {
162        RiskTier::Minor
163    } else if affected <= 10 && packages <= 2 {
164        RiskTier::Moderate
165    } else {
166        RiskTier::Risky
167    }
168}
169
170#[derive(Debug, Clone, Serialize)]
171pub struct GraphNode {
172    pub id: String,
173    pub label: String,
174    pub file: PathBuf,
175    pub symbol: Option<String>,
176    pub kind: NodeKind,
177    pub depth: usize,
178}
179
180#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
181#[serde(rename_all = "snake_case")]
182pub enum NodeKind {
183    File,
184    Export,
185}
186
187#[derive(Debug, Clone, Serialize)]
188pub struct GraphEdge {
189    pub from: String,
190    pub to: String,
191    pub kind: EdgeKind,
192    pub is_ambiguous: bool,
193}
194
195#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)]
196#[serde(rename_all = "snake_case")]
197pub enum EdgeKind {
198    ImportsNamed,
199    ImportsDefault,
200    ImportsNamespace,
201    ImportsDynamic,
202    ReexportsNamed,
203    ReexportsStar,
204    UsesJsxComponent,
205    RequiresModule,
206    CommonJsExport,
207    /// A `vi.mock("...")` / `jest.mock("...")` module reference in a test.
208    MocksModule,
209}
210
211#[derive(Debug, Clone)]
212pub struct ModuleState {
213    pub file: PathBuf,
214    pub public_exports: BTreeSet<String>,
215    pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
216    pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
217}
218
219impl ModuleState {
220    pub fn new(file: PathBuf) -> Self {
221        Self {
222            file,
223            public_exports: BTreeSet::new(),
224            export_to_locals: BTreeMap::new(),
225            local_to_exports: BTreeMap::new(),
226        }
227    }
228
229    pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
230        let exported = exported.into();
231        self.public_exports.insert(exported.clone());
232        if let Some(local) = local {
233            self.export_to_locals
234                .entry(exported.clone())
235                .or_default()
236                .insert(local.clone());
237            self.local_to_exports
238                .entry(local)
239                .or_default()
240                .insert(exported);
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::{Workspace, normalize_separators, package_key};
248
249    fn workspace(name: &str, root: &str) -> Workspace {
250        Workspace {
251            name: name.to_string(),
252            root: root.to_string(),
253        }
254    }
255
256    #[test]
257    fn normalize_separators_converts_backslashes() {
258        assert_eq!(
259            normalize_separators("src\\report\\tree.rs"),
260            "src/report/tree.rs"
261        );
262        assert_eq!(
263            normalize_separators("src/report/tree.rs"),
264            "src/report/tree.rs"
265        );
266        assert_eq!(normalize_separators(""), "");
267    }
268
269    #[test]
270    fn package_key_matches_workspace_with_backslash_path() {
271        let workspaces = vec![workspace("ui", "packages/ui"), workspace("repo", "")];
272        assert_eq!(
273            package_key("packages\\ui\\src\\button.tsx", &workspaces),
274            "packages/ui"
275        );
276        // Exact match on the workspace root itself.
277        assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
278    }
279
280    #[test]
281    fn package_key_handles_backslash_workspace_roots() {
282        // Workspace roots are labels too, so normalize them defensively as well.
283        let workspaces = vec![workspace("ui", "packages\\ui")];
284        assert_eq!(
285            package_key("packages/ui/src/button.tsx", &workspaces),
286            "packages/ui"
287        );
288        assert_eq!(
289            package_key("packages\\ui\\src\\button.tsx", &workspaces),
290            "packages/ui"
291        );
292    }
293
294    #[test]
295    fn package_key_falls_back_to_top_level_directory() {
296        assert_eq!(package_key("src\\lib\\util.rs", &[]), "src");
297        assert_eq!(package_key("src/lib/util.rs", &[]), "src");
298        assert_eq!(package_key("main.rs", &[]), ".");
299    }
300
301    #[test]
302    fn package_key_does_not_match_sibling_prefix() {
303        // "packages/ui-kit" must not match the "packages/ui" workspace.
304        let workspaces = vec![workspace("ui", "packages/ui")];
305        assert_eq!(
306            package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
307            "packages"
308        );
309    }
310}