Skip to main content

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//!
13//! # Two ways to be a partner
14//!
15//! The `co_changed` rule writes an edge on jaccard similarity over the two
16//! files' commit lists, above a floor. Similarity is the right measure for the
17//! graph — it keeps a busy file from being everyone's partner — but it is a
18//! *ratio*, so a file that changes with this one often and also changes a lot on
19//! its own scores low and gets no edge at all. On this repository
20//! `crates/cli/src/lib.rs` shares six of `install.rs`'s fifteen commits and
21//! scores 0.10, well under any floor worth setting, yet it is the third most
22//! frequent partner there is.
23//!
24//! So `impact` reads the commit lists too and names files by shared-commit
25//! *count* once the scored partners run out. They are labelled with the count
26//! rather than a score, because the two are not comparable and pretending
27//! otherwise would be the more misleading answer.
28
29use crate::db::GraphDb;
30use crate::repograph::facts::{
31    label_of, list_prop, neighbors, neighbors_both, owner_name, rank, score_of, symbol_file,
32};
33use crate::repograph::render::sanitize;
34use crate::Direction;
35use core_storage::fs::Fs;
36use serde::Serialize;
37use std::collections::{BTreeMap, BTreeSet};
38
39/// Symbols named per file. Past a handful the list stops being a warning and
40/// becomes a table of contents.
41const MAX_SYMBOLS: usize = 6;
42
43/// The paths a repository carries that are not its source: build output,
44/// vendored dependencies, generated bundles, and lockfiles nobody reads.
45///
46/// Applied when the user names no `--exclude` pattern of their own, which keeps
47/// them out of the history graph *and* out of the working-tree pass. It lives
48/// here, rather than only in the ingest, because a caller that builds a file
49/// list from a working tree — `impact`'s default diff — has to leave out
50/// exactly the paths the ingest left out, or it asks about files no store was
51/// ever going to hold and is told they are unknown.
52pub const DEFAULT_EXCLUDES: [&str; 6] = [
53    "target/",
54    "node_modules/",
55    "dist/",
56    ".git/",
57    "*.lock",
58    "*.min.js",
59];
60
61/// Whether `path` matches any of `patterns`.
62///
63/// A `foo/` pattern is a *directory prefix*. A `*.` pattern is a **file-name
64/// suffix**, not a single extension: `*.min.js` matches `ui/bundle.min.js` the
65/// same way `*.lock` matches `Cargo.lock`. Matching only the last dot segment
66/// would leave every compound suffix inert, and a compound suffix is exactly
67/// how generated files announce themselves. Anything else is a substring.
68#[must_use]
69pub fn path_excluded(path: &str, patterns: &[String]) -> bool {
70    patterns.iter().any(|p| {
71        if let Some(prefix) = p.strip_suffix('/') {
72            path.starts_with(&format!("{prefix}/"))
73        } else if let Some(suffix) = p.strip_prefix('*').filter(|s| s.starts_with('.')) {
74            // The suffix must follow something, so `*.lock` does not claim a
75            // path that is nothing but the suffix itself.
76            path.len() > suffix.len() && path.ends_with(suffix)
77        } else {
78            path.contains(p.as_str())
79        }
80    })
81}
82
83/// How much of the graph one `impact` call reports per file.
84#[derive(Debug, Clone, PartialEq)]
85pub struct ImpactOptions {
86    /// Weakest co-change score worth naming. Below this the pair changed
87    /// together a few times out of many, which is noise in a review.
88    pub min_score: f64,
89    /// Fewest shared commits worth naming a partner the score floor hid.
90    /// `0` turns the count pass off and leaves only scored partners.
91    pub min_shared_commits: usize,
92    pub max_partners: usize,
93    pub max_importers: usize,
94}
95
96impl Default for ImpactOptions {
97    fn default() -> Self {
98        Self {
99            min_score: 0.3,
100            min_shared_commits: MIN_SHARED_COMMITS,
101            max_partners: 6,
102            max_importers: 6,
103        }
104    }
105}
106
107/// Fewest commits two files must share before `impact` names one for the other
108/// on count alone. Two is a coincidence; three is a habit.
109pub const MIN_SHARED_COMMITS: usize = 3;
110
111/// One file the change reaches, and whether the caller has it open already.
112#[derive(Debug, Clone, PartialEq, Serialize)]
113pub struct Partner {
114    pub path: String,
115    /// The co-change score for a partner. An importer is not a statistical
116    /// association but a stated dependency, so its score is `1.0` and no
117    /// digest prints it.
118    pub score: f64,
119    /// Commits the two files share, for a partner found by count rather than by
120    /// score. `None` for a scored partner and for an importer, and a digest
121    /// prints the two differently — a count and a similarity do not compare.
122    pub shared_commits: Option<usize>,
123    /// The path is in the caller's set of modified files.
124    pub modified: bool,
125}
126
127/// What changing one file reaches.
128#[derive(Debug, Clone, PartialEq, Serialize)]
129pub struct FileImpact {
130    pub path: String,
131    /// The file's top author, by name.
132    pub owner: Option<String>,
133    /// Files that usually change with this one, strongest first.
134    pub partners: Vec<Partner>,
135    /// Files that import this one, by key.
136    pub importers: Vec<Partner>,
137    /// `(symbol, callers in other files)`, most called first.
138    pub symbols_used_elsewhere: Vec<(String, usize)>,
139}
140
141/// What a set of changed files reaches.
142#[derive(Debug, Clone, PartialEq, Serialize)]
143pub struct ImpactReport {
144    pub files: Vec<FileImpact>,
145    /// Requested paths the store has no `File` for: renamed, excluded from the
146    /// ingest, or not yet synced. Named rather than dropped, because a missing
147    /// answer and an empty one mean different things.
148    pub unknown: Vec<String>,
149}
150
151/// What changing `files` reaches, one report per file.
152///
153/// `modified` is the caller's own set — usually the whole diff — and decides
154/// only the `modified` flag; a partner in it is still reported. Paths are
155/// sorted and deduplicated, so the answer does not depend on the order the
156/// caller listed them in.
157#[must_use]
158pub fn impact<F: Fs>(
159    db: &GraphDb<F>,
160    files: &[String],
161    modified: &BTreeSet<String>,
162    opts: &ImpactOptions,
163) -> ImpactReport {
164    let mut wanted: Vec<&String> = files.iter().collect();
165    wanted.sort();
166    wanted.dedup();
167
168    let mut report = ImpactReport {
169        files: Vec::new(),
170        unknown: Vec::new(),
171    };
172    for path in wanted {
173        if label_of(db, path).as_deref() != Some("File") {
174            report.unknown.push(sanitize(path));
175            continue;
176        }
177        report.files.push(FileImpact {
178            path: sanitize(path),
179            owner: owner_name(db, path).map(|n| sanitize(&n)),
180            partners: partners(db, path, modified, opts),
181            importers: importers(db, path, modified, opts),
182            symbols_used_elsewhere: used_elsewhere(db, path),
183        });
184    }
185    report
186}
187
188/// Files this one changes with: the scored partners first, then the ones the
189/// score floor hides but the commit lists do not.
190fn partners<F: Fs>(
191    db: &GraphDb<F>,
192    path: &str,
193    modified: &BTreeSet<String>,
194    opts: &ImpactOptions,
195) -> Vec<Partner> {
196    let mut scored: Vec<(String, f64)> = neighbors_both(db, path, "CO_CHANGED")
197        .into_iter()
198        .map(|other| {
199            let score = score_of(db, "CO_CHANGED", path, &other).unwrap_or(0.0);
200            (other, score)
201        })
202        .filter(|(_, score)| *score >= opts.min_score)
203        .collect();
204    rank(&mut scored);
205    scored.truncate(opts.max_partners);
206
207    let named: BTreeSet<String> = scored.iter().map(|(other, _)| other.clone()).collect();
208    let mut out: Vec<Partner> = scored
209        .into_iter()
210        .map(|(other, score)| Partner {
211            modified: modified.contains(&other),
212            path: sanitize(&other),
213            score,
214            shared_commits: None,
215        })
216        .collect();
217
218    // Whatever room is left goes to the files that change with this one often
219    // enough to matter but score too low for an edge.
220    for (other, shared) in frequent_partners(db, path, &named, opts.min_shared_commits) {
221        if out.len() >= opts.max_partners {
222            break;
223        }
224        out.push(Partner {
225            modified: modified.contains(&other),
226            path: sanitize(&other),
227            score: 0.0,
228            shared_commits: Some(shared),
229        });
230    }
231    out
232}
233
234/// Files sharing at least `min` commits with `path`, most first, ties on the
235/// key. `skip` is what the scored pass already named; `min` of `0` is off.
236///
237/// The count comes from the `TOUCHED` edges of the commits on `path`, so it
238/// sees the pairs the rule's similarity floor left out. `File.commits` is
239/// capped by `ingest-git`, so the window counted over is the same one every
240/// other co-change answer here is drawn from.
241fn frequent_partners<F: Fs>(
242    db: &GraphDb<F>,
243    path: &str,
244    skip: &BTreeSet<String>,
245    min: usize,
246) -> Vec<(String, usize)> {
247    if min == 0 {
248        return Vec::new();
249    }
250    let mine: BTreeSet<String> = list_prop(db, path, "commits").into_iter().collect();
251    if mine.is_empty() {
252        return Vec::new();
253    }
254    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
255    for sha in &mine {
256        for other in neighbors(db, sha, "TOUCHED", Direction::Out) {
257            if other != path && !skip.contains(&other) {
258                *counts.entry(other).or_default() += 1;
259            }
260        }
261    }
262    let mut out: Vec<(String, usize)> = counts.into_iter().filter(|(_, n)| *n >= min).collect();
263    out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
264    out
265}
266
267/// Files that import this one, by key.
268fn importers<F: Fs>(
269    db: &GraphDb<F>,
270    path: &str,
271    modified: &BTreeSet<String>,
272    opts: &ImpactOptions,
273) -> Vec<Partner> {
274    neighbors(db, path, "IMPORTS", Direction::In)
275        .into_iter()
276        .take(opts.max_importers)
277        .map(|other| Partner {
278            modified: modified.contains(&other),
279            path: sanitize(&other),
280            score: 1.0,
281            shared_commits: None,
282        })
283        .collect()
284}
285
286/// The file's symbols that something outside it calls, and how many callers
287/// each has. A call from one symbol to another in the same file says nothing
288/// about what a change reaches.
289fn used_elsewhere<F: Fs>(db: &GraphDb<F>, path: &str) -> Vec<(String, usize)> {
290    let mut out: Vec<(String, usize)> = Vec::new();
291    for symbol in neighbors(db, path, "DEFINES", Direction::In) {
292        let callers = neighbors(db, &symbol, "CALLS", Direction::In)
293            .into_iter()
294            .filter(|caller| symbol_file(db, caller).as_deref() != Some(path))
295            .count();
296        if callers > 0 {
297            out.push((sanitize(&symbol), callers));
298        }
299    }
300    rank(&mut out);
301    out.truncate(MAX_SYMBOLS);
302    out
303}