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}