Skip to main content

core_api/repograph/
why.rs

1//! `why` — what links two things, with the evidence.
2//!
3//! A derived edge is a claim, and a claim an assistant is going to act on
4//! should come with what it was derived from. So every link this reports
5//! carries the rule that wrote it, the score it was written with, and the lines
6//! of the repository that make it true: the commits two files share, the line
7//! an import sits on, the line a call is made from, the file an author knows
8//! both through, the heading a document mentions something under.
9//!
10//! When no rule links the two at all, the commits they share are still an
11//! answer — the co-change rule only writes an edge above a similarity floor,
12//! and a pair that changes together often but also changes apart falls under
13//! it. Failing that the answer is the shortest walk between them — see
14//! [`shortest_path`](crate::repograph::shortest_path) — and when there is not
15//! even one of those, `why` says so rather than implying a connection nobody
16//! can name.
17
18use crate::db::GraphDb;
19use crate::repograph::facts::{
20    commit_fact, evidence_line, evidence_lines, list_prop, neighbors, str_prop, CommitFact,
21};
22use crate::repograph::impact::MIN_SHARED_COMMITS;
23use crate::repograph::owners::SHA_LEN;
24use crate::repograph::path::{shortest_path, MAX_HOPS, PATH_EDGES};
25use crate::repograph::render::{sanitize, ymd};
26use crate::Direction;
27use core_storage::fs::Fs;
28use serde::Serialize;
29use std::collections::BTreeSet;
30
31/// Lines of evidence kept per link. Three commits say "these two move
32/// together"; thirty say it no better and crowd out the next link.
33const MAX_EVIDENCE: usize = 3;
34
35/// How `a` and `b` are linked, if they are.
36#[derive(Debug, Clone, PartialEq, Serialize)]
37pub struct WhyReport {
38    pub a: String,
39    pub b: String,
40    /// Every rule-written edge between them, in either direction.
41    pub links: Vec<WhyLink>,
42    /// `(edge type, node reached)` hops, filled only when no rule links them
43    /// directly, they share too few commits to say anything, and a walk of at
44    /// most [`MAX_HOPS`] edges connects them.
45    pub path: Vec<(String, String)>,
46    /// Commits both were touched by, filled only when no `CO_CHANGED` edge
47    /// already carries them.
48    ///
49    /// The `co_changed` rule writes an edge on jaccard similarity, so two files
50    /// that change together often and *also* change apart carry no edge at all
51    /// and would otherwise be reported as unrelated — or as related only by
52    /// whatever else happens to link them. See
53    /// [`MIN_SHARED_COMMITS`](crate::repograph::MIN_SHARED_COMMITS).
54    pub shared: Option<SharedCommits>,
55    /// Whichever of `a` and `b` the store has never heard of.
56    pub unknown: Vec<String>,
57}
58
59/// How often two files were touched by the same commit, and by which commits.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct SharedCommits {
62    /// Commits that touched both.
63    pub count: usize,
64    /// The newest few of them, as a digest quotes a commit.
65    pub evidence: Vec<String>,
66}
67
68/// One rule-written edge, and what makes it true.
69#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct WhyLink {
71    /// The rule that wrote the edge.
72    pub rule: String,
73    pub edge_type: String,
74    /// `a→b` or `b→a`, in terms of the keys the caller asked about.
75    pub direction: String,
76    /// The score the rule matched at. `None` for a rule that records none.
77    pub score: Option<f64>,
78    /// For a via-hop rule, the edge type the rule hopped over to find its
79    /// candidates. The evidence names the node it hopped through.
80    pub via: Option<String>,
81    /// The repository facts behind the edge, strongest or newest first.
82    pub evidence: Vec<String>,
83}
84
85/// Why `a` and `b` are linked.
86///
87/// Deterministic: links come back in the engine's `(rule, edge type)` order,
88/// evidence is sorted before it is cut, and the path — when there is one — is
89/// the same shortest walk every time.
90#[must_use]
91pub fn why<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> WhyReport {
92    let mut report = WhyReport {
93        a: sanitize(a),
94        b: sanitize(b),
95        links: Vec::new(),
96        path: Vec::new(),
97        shared: None,
98        unknown: Vec::new(),
99    };
100    for key in [a, b] {
101        if !db.has_node(key) {
102            report.unknown.push(sanitize(key));
103        }
104    }
105    report.unknown.sort();
106    report.unknown.dedup();
107    if !report.unknown.is_empty() {
108        return report;
109    }
110
111    for e in db.explain(a, b).unwrap_or_default() {
112        let forward = e.src_key == a;
113        report.links.push(WhyLink {
114            evidence: evidence(db, &e.edge_type, &e.src_key, &e.dst_key),
115            rule: sanitize(&e.rule),
116            edge_type: sanitize(&e.edge_type),
117            direction: if forward { "a→b" } else { "b→a" }.to_string(),
118            score: e.weight,
119            via: e.via_edge.as_deref().map(sanitize),
120        });
121    }
122    // The engine returns provenance in its own stable order; sorting on the
123    // fields themselves means the answer depends on what the links *are*
124    // rather than on the order two stores happened to intern their ids in.
125    report.links.sort_by(|x, y| {
126        x.direction
127            .cmp(&y.direction)
128            .then(x.edge_type.cmp(&y.edge_type))
129            .then(x.rule.cmp(&y.rule))
130            .then(
131                y.score
132                    .partial_cmp(&x.score)
133                    .unwrap_or(std::cmp::Ordering::Equal),
134            )
135            .then(x.evidence.cmp(&y.evidence))
136    });
137    // No `CO_CHANGED` edge is not the same as no co-change: the rule only
138    // writes one above a similarity floor, and a pair that changes together
139    // often but also changes apart falls under it. That the two import each
140    // other says nothing about how often they move together, so the commits are
141    // reported alongside whatever links there are — and, when there are none,
142    // instead of a three-hop walk that says less.
143    if !report.links.iter().any(|l| l.edge_type == "CO_CHANGED") {
144        report.shared = shared_commit_count(db, a, b);
145    }
146    if report.links.is_empty() && report.shared.is_none() {
147        report.path = shortest_path(db, a, b, &PATH_EDGES, MAX_HOPS);
148    }
149    report
150}
151
152/// The commits `a` and `b` share, when there are at least
153/// [`MIN_SHARED_COMMITS`] of them. `None` for anything else — a pair that
154/// shares one commit shares a coincidence, and a node with no `commits` list
155/// (a symbol, an author) shares nothing.
156fn shared_commit_count<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Option<SharedCommits> {
157    let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
158    let count = list_prop(db, a, "commits")
159        .into_iter()
160        .collect::<BTreeSet<String>>()
161        .intersection(&theirs)
162        .count();
163    (count >= MIN_SHARED_COMMITS).then(|| SharedCommits {
164        count,
165        evidence: shared_commits(db, a, b),
166    })
167}
168
169/// The repository facts behind one edge, in the form its kind is read in.
170///
171/// An edge type nothing is recorded for — an auto-FK edge such as `DEFINES`,
172/// whose evidence is the prop it was derived from and is already in the key —
173/// gets none, and the digest prints the link alone.
174fn evidence<F: Fs>(db: &GraphDb<F>, edge_type: &str, src: &str, dst: &str) -> Vec<String> {
175    match edge_type {
176        "CO_CHANGED" => shared_commits(db, src, dst),
177        "IMPORTS" => match evidence_line(&list_prop(db, src, "import_lines"), dst) {
178            Some(line) => vec![sanitize(&format!("{src} line {line}: import {dst}"))],
179            None => vec![sanitize(&format!("{src} imports {dst}"))],
180        },
181        // Every site, not the first: one edge stands for however many times the
182        // call is written, and the lines are what a change has to visit.
183        "CALLS" => match evidence_lines(&list_prop(db, src, "call_lines"), dst) {
184            lines if lines.is_empty() => vec![sanitize(&format!("{src} calls {dst}"))],
185            lines => {
186                let shown: Vec<String> = lines.iter().map(u32::to_string).collect();
187                vec![sanitize(&format!(
188                    "{src} calls {dst} at {} {}",
189                    if lines.len() == 1 { "line" } else { "lines" },
190                    shown.join(", ")
191                ))]
192            }
193        },
194        "KNOWS" => via_files(db, src, dst),
195        "MENTIONS" => vec![mention(db, src, dst)],
196        _ => Vec::new(),
197    }
198}
199
200/// The commits both files were touched by, newest first.
201fn shared_commits<F: Fs>(db: &GraphDb<F>, a: &str, b: &str) -> Vec<String> {
202    let theirs: BTreeSet<String> = list_prop(db, b, "commits").into_iter().collect();
203    let mut shared: Vec<CommitFact> = list_prop(db, a, "commits")
204        .into_iter()
205        .filter(|sha| theirs.contains(sha))
206        .filter_map(|sha| commit_fact(db, &sha))
207        .collect();
208    shared.sort_by(|x, y| y.ts.cmp(&x.ts).then(x.sha.cmp(&y.sha)));
209    shared.dedup_by(|x, y| x.sha == y.sha);
210    shared
211        .into_iter()
212        .take(MAX_EVIDENCE)
213        .map(|c| {
214            let short: String = c.sha.chars().take(SHA_LEN).collect();
215            sanitize(&format!("{short} {} {}", ymd(c.ts), c.subject))
216        })
217        .collect()
218}
219
220/// The files an author knows `file` through: the ones they own that share
221/// commits with it, which is what the `knows` rule hopped over to find them.
222fn via_files<F: Fs>(db: &GraphDb<F>, author: &str, file: &str) -> Vec<String> {
223    let theirs: BTreeSet<String> = list_prop(db, file, "commits").into_iter().collect();
224    let mut scored: Vec<(usize, String)> = neighbors(db, author, "TOP_AUTHOR", Direction::In)
225        .into_iter()
226        .filter(|owned| owned != file)
227        .map(|owned| {
228            let shared = list_prop(db, &owned, "commits")
229                .into_iter()
230                .filter(|sha| theirs.contains(sha))
231                .count();
232            (shared, owned)
233        })
234        .filter(|(shared, _)| *shared > 0)
235        .collect();
236    // Most shared commits first, ties on the key: the same order the digest
237    // would rank any other association in.
238    scored.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1)));
239    scored
240        .into_iter()
241        .take(MAX_EVIDENCE)
242        .map(|(shared, owned)| {
243            sanitize(&format!(
244                "via {owned} ({shared} shared commit{})",
245                if shared == 1 { "" } else { "s" }
246            ))
247        })
248        .collect()
249}
250
251/// Where in a document a file is mentioned: the heading it sits under.
252///
253/// The mention itself carries no line, so the line is found in the document's
254/// stored `body` — the first one naming the file — and the heading is the
255/// nearest one above it. A document whose body was not stored still has its
256/// headings, and the first of those is what it is about.
257fn mention<F: Fs>(db: &GraphDb<F>, doc: &str, file: &str) -> String {
258    let headings = list_prop(db, doc, "headings");
259    let nearest = str_prop(db, doc, "body").and_then(|body| {
260        let lines: Vec<&str> = body.lines().collect();
261        let at = lines.iter().position(|l| l.contains(file))?;
262        lines[..=at]
263            .iter()
264            .rev()
265            .find_map(|l| heading_text(l).map(str::to_string))
266    });
267    match nearest.or_else(|| headings.first().cloned()) {
268        Some(heading) => sanitize(&format!("{doc} mentions {file} under \"{heading}\"")),
269        None => sanitize(&format!("{doc} mentions {file}")),
270    }
271}
272
273/// The text of a Markdown ATX heading, if the line is one.
274fn heading_text(line: &str) -> Option<&str> {
275    let trimmed = line.trim_start();
276    let hashes = trimmed.chars().take_while(|c| *c == '#').count();
277    if hashes == 0 || hashes > 6 {
278        return None;
279    }
280    let rest = trimmed.get(hashes..)?;
281    if !rest.starts_with(char::is_whitespace) {
282        return None;
283    }
284    let title = rest.trim().trim_end_matches('#').trim();
285    (!title.is_empty()).then_some(title)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::heading_text;
291
292    #[test]
293    fn a_heading_is_hashes_a_space_and_a_title() {
294        assert_eq!(heading_text("## Rules"), Some("Rules"));
295        assert_eq!(heading_text("  # Top  "), Some("Top"));
296        assert_eq!(heading_text("### Closed ###"), Some("Closed"));
297        assert_eq!(heading_text("#no-space"), None);
298        assert_eq!(heading_text("####### too deep"), None);
299        assert_eq!(heading_text("plain text"), None);
300        assert_eq!(heading_text("#"), None);
301    }
302}