core_api/repograph/impact.rs
1//! `impact` — what else a change touches.
2//!
3//! Given the files a diff changes, three questions have useful answers before
4//! the change is finished: which files usually change with these and are *not*
5//! in the diff, who imports them, and which of their symbols are called from
6//! elsewhere. Each is a fact the graph already holds — the co-change rule, the
7//! import edges, the call edges — and each names something a reviewer would
8//! otherwise have to remember.
9//!
10//! Partners already in the diff are kept and marked `modified`, because "you
11//! changed both, as usual" is as useful as "you changed one of the two".
12
13use crate::db::GraphDb;
14use crate::repograph::facts::{
15 label_of, neighbors, neighbors_both, owner_name, rank, score_of, symbol_file,
16};
17use crate::repograph::render::sanitize;
18use crate::Direction;
19use core_storage::fs::Fs;
20use serde::Serialize;
21use std::collections::BTreeSet;
22
23/// Symbols named per file. Past a handful the list stops being a warning and
24/// becomes a table of contents.
25const MAX_SYMBOLS: usize = 6;
26
27/// The paths a repository carries that are not its source: build output,
28/// vendored dependencies, generated bundles, and lockfiles nobody reads.
29///
30/// Applied when the user names no `--exclude` pattern of their own, which keeps
31/// them out of the history graph *and* out of the working-tree pass. It lives
32/// here, rather than only in the ingest, because a caller that builds a file
33/// list from a working tree — `impact`'s default diff — has to leave out
34/// exactly the paths the ingest left out, or it asks about files no store was
35/// ever going to hold and is told they are unknown.
36pub const DEFAULT_EXCLUDES: [&str; 6] = [
37 "target/",
38 "node_modules/",
39 "dist/",
40 ".git/",
41 "*.lock",
42 "*.min.js",
43];
44
45/// Whether `path` matches any of `patterns`.
46///
47/// A `foo/` pattern is a *directory prefix*. A `*.` pattern is a **file-name
48/// suffix**, not a single extension: `*.min.js` matches `ui/bundle.min.js` the
49/// same way `*.lock` matches `Cargo.lock`. Matching only the last dot segment
50/// would leave every compound suffix inert, and a compound suffix is exactly
51/// how generated files announce themselves. Anything else is a substring.
52#[must_use]
53pub fn path_excluded(path: &str, patterns: &[String]) -> bool {
54 patterns.iter().any(|p| {
55 if let Some(prefix) = p.strip_suffix('/') {
56 path.starts_with(&format!("{prefix}/"))
57 } else if let Some(suffix) = p.strip_prefix('*').filter(|s| s.starts_with('.')) {
58 // The suffix must follow something, so `*.lock` does not claim a
59 // path that is nothing but the suffix itself.
60 path.len() > suffix.len() && path.ends_with(suffix)
61 } else {
62 path.contains(p.as_str())
63 }
64 })
65}
66
67/// How much of the graph one `impact` call reports per file.
68#[derive(Debug, Clone, PartialEq)]
69pub struct ImpactOptions {
70 /// Weakest co-change score worth naming. Below this the pair changed
71 /// together a few times out of many, which is noise in a review.
72 pub min_score: f64,
73 pub max_partners: usize,
74 pub max_importers: usize,
75}
76
77impl Default for ImpactOptions {
78 fn default() -> Self {
79 Self {
80 min_score: 0.3,
81 max_partners: 6,
82 max_importers: 6,
83 }
84 }
85}
86
87/// One file the change reaches, and whether the caller has it open already.
88#[derive(Debug, Clone, PartialEq, Serialize)]
89pub struct Partner {
90 pub path: String,
91 /// The co-change score for a partner. An importer is not a statistical
92 /// association but a stated dependency, so its score is `1.0` and no
93 /// digest prints it.
94 pub score: f64,
95 /// The path is in the caller's set of modified files.
96 pub modified: bool,
97}
98
99/// What changing one file reaches.
100#[derive(Debug, Clone, PartialEq, Serialize)]
101pub struct FileImpact {
102 pub path: String,
103 /// The file's top author, by name.
104 pub owner: Option<String>,
105 /// Files that usually change with this one, strongest first.
106 pub partners: Vec<Partner>,
107 /// Files that import this one, by key.
108 pub importers: Vec<Partner>,
109 /// `(symbol, callers in other files)`, most called first.
110 pub symbols_used_elsewhere: Vec<(String, usize)>,
111}
112
113/// What a set of changed files reaches.
114#[derive(Debug, Clone, PartialEq, Serialize)]
115pub struct ImpactReport {
116 pub files: Vec<FileImpact>,
117 /// Requested paths the store has no `File` for: renamed, excluded from the
118 /// ingest, or not yet synced. Named rather than dropped, because a missing
119 /// answer and an empty one mean different things.
120 pub unknown: Vec<String>,
121}
122
123/// What changing `files` reaches, one report per file.
124///
125/// `modified` is the caller's own set — usually the whole diff — and decides
126/// only the `modified` flag; a partner in it is still reported. Paths are
127/// sorted and deduplicated, so the answer does not depend on the order the
128/// caller listed them in.
129#[must_use]
130pub fn impact<F: Fs>(
131 db: &GraphDb<F>,
132 files: &[String],
133 modified: &BTreeSet<String>,
134 opts: &ImpactOptions,
135) -> ImpactReport {
136 let mut wanted: Vec<&String> = files.iter().collect();
137 wanted.sort();
138 wanted.dedup();
139
140 let mut report = ImpactReport {
141 files: Vec::new(),
142 unknown: Vec::new(),
143 };
144 for path in wanted {
145 if label_of(db, path).as_deref() != Some("File") {
146 report.unknown.push(sanitize(path));
147 continue;
148 }
149 report.files.push(FileImpact {
150 path: sanitize(path),
151 owner: owner_name(db, path).map(|n| sanitize(&n)),
152 partners: partners(db, path, modified, opts),
153 importers: importers(db, path, modified, opts),
154 symbols_used_elsewhere: used_elsewhere(db, path),
155 });
156 }
157 report
158}
159
160/// Files this one changes with, strongest first, above the score floor.
161fn partners<F: Fs>(
162 db: &GraphDb<F>,
163 path: &str,
164 modified: &BTreeSet<String>,
165 opts: &ImpactOptions,
166) -> Vec<Partner> {
167 let mut scored: Vec<(String, f64)> = neighbors_both(db, path, "CO_CHANGED")
168 .into_iter()
169 .map(|other| {
170 let score = score_of(db, "CO_CHANGED", path, &other).unwrap_or(0.0);
171 (other, score)
172 })
173 .filter(|(_, score)| *score >= opts.min_score)
174 .collect();
175 rank(&mut scored);
176 scored.truncate(opts.max_partners);
177 scored
178 .into_iter()
179 .map(|(other, score)| Partner {
180 modified: modified.contains(&other),
181 path: sanitize(&other),
182 score,
183 })
184 .collect()
185}
186
187/// Files that import this one, by key.
188fn importers<F: Fs>(
189 db: &GraphDb<F>,
190 path: &str,
191 modified: &BTreeSet<String>,
192 opts: &ImpactOptions,
193) -> Vec<Partner> {
194 neighbors(db, path, "IMPORTS", Direction::In)
195 .into_iter()
196 .take(opts.max_importers)
197 .map(|other| Partner {
198 modified: modified.contains(&other),
199 path: sanitize(&other),
200 score: 1.0,
201 })
202 .collect()
203}
204
205/// The file's symbols that something outside it calls, and how many callers
206/// each has. A call from one symbol to another in the same file says nothing
207/// about what a change reaches.
208fn used_elsewhere<F: Fs>(db: &GraphDb<F>, path: &str) -> Vec<(String, usize)> {
209 let mut out: Vec<(String, usize)> = Vec::new();
210 for symbol in neighbors(db, path, "DEFINES", Direction::In) {
211 let callers = neighbors(db, &symbol, "CALLS", Direction::In)
212 .into_iter()
213 .filter(|caller| symbol_file(db, caller).as_deref() != Some(path))
214 .count();
215 if callers > 0 {
216 out.push((sanitize(&symbol), callers));
217 }
218 }
219 rank(&mut out);
220 out.truncate(MAX_SYMBOLS);
221 out
222}