Skip to main content

choir_queue/
blast.rs

1//! Blast radius: how far a change can reach (D23, the unbuilt half).
2//!
3//! D23 registers "blast-radius-gated landing" but the term had no
4//! operational definition anywhere in the code. This is signal #1 of the
5//! five the D23 research ranked by cost-to-compute against predictive
6//! value: **reverse-dependency reach in the cargo unit graph**. It was
7//! ranked first because it needs *zero historical data* — no labelled
8//! corpus, no revert history, no coverage run. Pure graph traversal.
9//!
10//! Deliberately measured and recorded, **not gated on**. A gate needs a
11//! threshold, a threshold needs calibration, and calibration needs the
12//! base rate we have not measured yet (the revised D23 tripwire). So
13//! this instruments first: compute the score, attach it to the change,
14//! and let it accumulate into the data a threshold could later be fitted
15//! against. Instrumenting before gating is the whole point.
16//!
17//! **Granularity is crate-level, and that is a real limitation, not a
18//! detail.** A one-character change to `choir-hash` scores exactly the
19//! same as rewriting it, because both touch the same package and the
20//! same set of packages depend on it. The score answers "how much of the
21//! workspace *could* this change break", never "how likely is it to".
22//! Module- or item-level reach would need parsing Rust, which is a
23//! different and much larger job.
24//!
25//! Input is `cargo metadata --no-deps` JSON rather than a path, so the
26//! analysis is a pure function over data. [`workspace_metadata`] is the
27//! separate impure half that shells out. That split keeps the tests
28//! hermetic and, more practically, stops a test from invoking cargo
29//! inside cargo.
30
31use std::collections::{BTreeMap, BTreeSet};
32
33/// How far a change reaches through the workspace dependency graph.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct BlastRadius {
36    /// Packages that directly contain at least one changed file, sorted.
37    pub touched: Vec<String>,
38    /// Every package that transitively depends on a touched one, plus the
39    /// touched packages themselves, sorted. This is the set a break could
40    /// propagate to.
41    pub reached: Vec<String>,
42    /// Workspace packages in total, the denominator of [`Self::fraction`].
43    pub total: usize,
44    /// Changed files that fell outside every package (workspace-root
45    /// files like `DECISIONS.md`, or a path from another repo), sorted.
46    ///
47    /// Kept rather than dropped: a change that is *entirely* unattributed
48    /// scores zero, and zero should be distinguishable from "touched
49    /// nothing that matters" by looking at this field.
50    pub unattributed: Vec<String>,
51}
52
53impl BlastRadius {
54    /// Reached packages as a fraction of the workspace, in `0.0..=1.0`.
55    ///
56    /// An empty workspace scores 0.0 rather than dividing by zero.
57    #[must_use]
58    pub fn fraction(&self) -> f64 {
59        if self.total == 0 {
60            return 0.0;
61        }
62        self.reached.len() as f64 / self.total as f64
63    }
64}
65
66/// One workspace package as the analysis needs it.
67struct Package {
68    name: String,
69    /// Directory containing its `Cargo.toml`, with a trailing separator so
70    /// prefix matching cannot pair `choir-hash` with `choir-hashing`.
71    dir: String,
72    /// Names of its dependencies that are also workspace members.
73    deps: Vec<String>,
74}
75
76/// Computes the blast radius of `changed` against `metadata`, the output
77/// of `cargo metadata --no-deps --format-version 1`.
78///
79/// Paths in `changed` may be absolute or relative to the workspace root;
80/// both are matched against each package's directory. A file is
81/// attributed to the *longest* matching package directory, so a nested
82/// package wins over the workspace root.
83///
84/// # Errors
85///
86/// Returns a description when `metadata` is not JSON, or lacks the
87/// `packages` array with `name` and `manifest_path` on each entry.
88///
89/// # Examples
90///
91/// ```
92/// # use choir_queue::blast::blast_radius;
93/// let metadata = r#"{"packages":[
94///   {"name":"lib","manifest_path":"/w/lib/Cargo.toml","dependencies":[]},
95///   {"name":"app","manifest_path":"/w/app/Cargo.toml",
96///    "dependencies":[{"name":"lib"}]}
97/// ]}"#;
98/// // Touching the leaf reaches only itself.
99/// let r = blast_radius(metadata, &["/w/app/src/main.rs"]).unwrap();
100/// assert_eq!(r.reached, ["app"]);
101/// // Touching the library reaches its dependent too.
102/// let r = blast_radius(metadata, &["/w/lib/src/lib.rs"]).unwrap();
103/// assert_eq!(r.reached, ["app", "lib"]);
104/// ```
105pub fn blast_radius(metadata: &str, changed: &[&str]) -> Result<BlastRadius, String> {
106    let (packages, root) = parse_packages(metadata)?;
107    let names: BTreeSet<&str> = packages.iter().map(|p| p.name.as_str()).collect();
108
109    // Reverse edges: dependency -> everything that depends on it.
110    let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
111    for pkg in &packages {
112        for dep in &pkg.deps {
113            if names.contains(dep.as_str()) {
114                dependents.entry(dep.as_str()).or_default().push(&pkg.name);
115            }
116        }
117    }
118
119    let mut touched = BTreeSet::new();
120    let mut unattributed = BTreeSet::new();
121    for path in changed {
122        // Both the package dirs and the changed path are reduced to
123        // workspace-relative form, so a caller may pass either an
124        // absolute path or one relative to the workspace root without the
125        // matching rule needing two branches.
126        let rel = strip_root(path, &root);
127        // Longest directory wins, so a package nested inside another is
128        // attributed to the inner one.
129        match packages
130            .iter()
131            .filter(|p| rel.starts_with(strip_root(&p.dir, &root)))
132            .max_by_key(|p| p.dir.len())
133        {
134            Some(pkg) => {
135                touched.insert(pkg.name.as_str());
136            }
137            None => {
138                unattributed.insert((*path).to_string());
139            }
140        }
141    }
142
143    // Transitive closure over reverse edges. A cyclic graph would loop
144    // forever without the visited set; cargo forbids cycles, but this
145    // does not depend on cargo continuing to.
146    let mut reached: BTreeSet<&str> = touched.clone();
147    let mut frontier: Vec<&str> = touched.iter().copied().collect();
148    while let Some(name) = frontier.pop() {
149        for &dependent in dependents.get(name).into_iter().flatten() {
150            if reached.insert(dependent) {
151                frontier.push(dependent);
152            }
153        }
154    }
155
156    Ok(BlastRadius {
157        touched: touched.into_iter().map(String::from).collect(),
158        reached: reached.into_iter().map(String::from).collect(),
159        total: packages.len(),
160        unattributed: unattributed.into_iter().collect(),
161    })
162}
163
164/// Reduces `path` to workspace-relative form. A path already relative, or
165/// one under a different root, is returned unchanged — so a stray absolute
166/// path from elsewhere simply fails to match any package and lands in
167/// `unattributed` rather than being silently attributed to one.
168fn strip_root<'a>(path: &'a str, root: &str) -> &'a str {
169    if root.is_empty() {
170        return path;
171    }
172    path.strip_prefix(root)
173        .map_or(path, |rest| rest.trim_start_matches('/'))
174}
175
176/// Pulls the fields the analysis needs out of `cargo metadata` JSON.
177fn parse_packages(metadata: &str) -> Result<(Vec<Package>, String), String> {
178    let root: serde_json::Value =
179        serde_json::from_str(metadata).map_err(|e| format!("metadata is not json: {e}"))?;
180    let list = root
181        .get("packages")
182        .and_then(serde_json::Value::as_array)
183        .ok_or("metadata has no `packages` array")?;
184    // Absent in hand-written metadata; then everything stays as given,
185    // which is correct as long as the caller is consistent about form.
186    let root_dir = root
187        .get("workspace_root")
188        .and_then(serde_json::Value::as_str)
189        .unwrap_or_default()
190        .to_string();
191    list.iter()
192        .map(|p| {
193            let name = p
194                .get("name")
195                .and_then(serde_json::Value::as_str)
196                .ok_or("package has no `name`")?
197                .to_string();
198            let manifest = p
199                .get("manifest_path")
200                .and_then(serde_json::Value::as_str)
201                .ok_or_else(|| format!("package {name} has no `manifest_path`"))?;
202            let dir = manifest
203                .strip_suffix("Cargo.toml")
204                .unwrap_or(manifest)
205                .to_string();
206            let deps = p
207                .get("dependencies")
208                .and_then(serde_json::Value::as_array)
209                .map(|ds| {
210                    ds.iter()
211                        .filter_map(|d| d.get("name").and_then(serde_json::Value::as_str))
212                        .map(String::from)
213                        .collect()
214                })
215                .unwrap_or_default();
216            Ok(Package { name, dir, deps })
217        })
218        .collect::<Result<Vec<_>, String>>()
219        .map(|pkgs| (pkgs, root_dir))
220}
221
222/// Runs `cargo metadata --no-deps --format-version 1` in `root` and
223/// returns its JSON, for feeding to [`blast_radius`].
224///
225/// Shells out rather than linking a manifest parser, the same posture the
226/// workspace takes with `curl`, `openssl`, and `mergiraf`. `--no-deps`
227/// keeps it offline: registry packages are never resolved.
228///
229/// # Errors
230///
231/// Returns a description if cargo cannot be spawned or exits nonzero.
232pub fn workspace_metadata(root: &std::path::Path) -> Result<String, String> {
233    let out = std::process::Command::new("cargo")
234        .args(["metadata", "--no-deps", "--format-version", "1"])
235        .current_dir(root)
236        .output()
237        .map_err(|e| format!("cargo metadata: {e}"))?;
238    if !out.status.success() {
239        return Err(format!(
240            "cargo metadata failed: {}",
241            String::from_utf8_lossy(&out.stderr).trim()
242        ));
243    }
244    String::from_utf8(out.stdout).map_err(|e| format!("metadata is not utf-8: {e}"))
245}