Skip to main content

differential_engine/artefact/
graph.rs

1//! The class dependency graph: definition → use edges between shape classes.
2//!
3//! Built once, from classes, before the model runs (ADR 0022). Two consumers
4//! read it: the artefact the model fetches from, and the ordering stage, which
5//! contracts it onto groups.
6//!
7//! **It is a fact about the diff, not about the grouping.** The stage that used
8//! to build it worked from groups, so a symbol two classes defined produced an
9//! edge only when the model happened to merge those two classes. What depends
10//! on what cannot turn on how a label was drawn.
11//!
12//! Extraction is a domain use case with pluggable readers ([`super::symbols`]);
13//! no indexer. It reads WHOLE FILES from the head tree, because a line inside a
14//! block comment cannot be told from code on its own. A file no reader claims
15//! contributes nothing — a guess costs more than silence. Precision is allowed to be low (ADR 0007): a wrong edge
16//! misorders, and it can never hide content. Every edge carries the symbols
17//! that produced it, so a consumer can judge one by its cause rather than take
18//! it on trust.
19
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21
22use super::symbols::{FileSymbols, SymbolReaders};
23use crate::EngineError;
24use crate::model::DiffView;
25use crate::ports::ObjectReader;
26use crate::schema;
27use crate::shape::Partition;
28
29/// What each class introduces, and which classes it consumes. Both indexed by
30/// class index, parallel to `Partition::classes`.
31pub struct ClassGraph {
32    pub defines: Vec<Vec<String>>,
33    pub depends_on: Vec<Vec<schema::ClassEdge>>,
34}
35
36/// Build the graph over the **added** lines of every class: what the change
37/// introduces, and what the changed code now calls.
38///
39/// Hunks in generated files contribute no symbols. A lockfile would otherwise
40/// appear to define half the dependency tree. This is classification, never
41/// enumeration — the class, its hunks and its files all still exist
42/// (ADR 0005/0012).
43pub fn build<G: ObjectReader>(
44    git: &G,
45    head: &str,
46    view: &DiffView,
47    partition: &Partition,
48    symbols: &SymbolReaders,
49) -> Result<ClassGraph, EngineError> {
50    let parsed = parse_files(git, head, view, symbols)?;
51
52    let n = partition.classes.len();
53    let mut defs: Vec<BTreeSet<Vec<u8>>> = vec![BTreeSet::new(); n];
54    let mut refs: Vec<BTreeSet<Vec<u8>>> = vec![BTreeSet::new(); n];
55
56    for (ci, members) in partition.classes.iter().enumerate() {
57        for &hi in members {
58            let h = &view.hunks[hi];
59            let file = view.file_of(h);
60            // Neither contributes a symbol, and each for its own reason.
61            // Generated content defines nothing — a lockfile would otherwise
62            // appear to define half the dependency tree. A gitlink's only added
63            // line is `Subproject commit <oid>`: diff prose about a commit this
64            // repository does not have, whose words are plausible identifiers.
65            //
66            // Both skips belong HERE rather than only in `parse_files`. A
67            // category excluded from the blob read still reaches the fallback,
68            // which is how the gitlink's prose used to become references.
69            if file.generated.is_some() || file.submodule.is_some() {
70                continue;
71            }
72            // No entry means no reader claimed the file, or none could read
73            // it. Either way the class gains no symbols from this hunk: the
74            // domain never substitutes one reader's answer for another's, and
75            // never invents one of its own.
76            if let Some(fs) = parsed.get(&h.file) {
77                for i in 0..h.added.len() {
78                    let line = h.new_start + i as u32;
79                    defs[ci].extend(fs.defines_at(line).iter().cloned());
80                    refs[ci].extend(fs.references_at(line).iter().cloned());
81                }
82            }
83        }
84    }
85
86    // Only symbols defined by exactly ONE class create edges. A symbol two
87    // classes define is ambiguous, and this heuristic cannot say which one a
88    // reference meant; a precise `Language` (ADR 0015) would resolve it
89    // instead of dropping it.
90    let mut definer: HashMap<&[u8], Option<usize>> = HashMap::new();
91    for (ci, d) in defs.iter().enumerate() {
92        for sym in d {
93            definer
94                .entry(sym.as_slice())
95                .and_modify(|e| *e = None)
96                .or_insert(Some(ci));
97        }
98    }
99
100    let mut depends_on: Vec<Vec<schema::ClassEdge>> = Vec::with_capacity(n);
101    for (ci, r) in refs.iter().enumerate() {
102        // BTreeMap keyed by the defining class index: edges come out sorted by
103        // class number, which is `C0`, `C1`, … in the ids too.
104        let mut by_target: BTreeMap<usize, Vec<String>> = BTreeMap::new();
105        for sym in r {
106            if let Some(&Some(def_ci)) = definer.get(sym.as_slice())
107                && def_ci != ci
108            {
109                by_target.entry(def_ci).or_default().push(text(sym));
110            }
111        }
112        depends_on.push(
113            by_target
114                .into_iter()
115                .map(|(target, via)| schema::ClassEdge {
116                    on: format!("C{target}"),
117                    via,
118                })
119                .collect(),
120        );
121    }
122
123    Ok(ClassGraph {
124        defines: defs
125            .iter()
126            .map(|d| d.iter().map(|s| text(s)).collect())
127            .collect(),
128        depends_on,
129    })
130}
131
132/// Parse every file that can contribute a symbol, once — keyed by file index.
133///
134/// **Whole files, from the head tree.** The hooks used to see one diff line at
135/// a time, which cannot tell a line inside a block comment from code. So the
136/// content comes from the odb and the hunks say which of its lines to read.
137///
138/// One bulk read for the lot: a blob costs a process and a process costs
139/// milliseconds (ADR 0021). A file that can contribute nothing is never read —
140/// generated content defines nothing (a lockfile would otherwise appear to
141/// define half the dependency tree), a binary carries no lines, and a file
142/// whose every hunk is a pure deletion has no added line to attribute.
143///
144/// A gitlink is excluded twice over: there is no blob behind the path, so asking
145/// for one is an error rather than an absence, and `build` skips it outright so
146/// its pseudo-hunk never reaches the fallback either.
147fn parse_files<G: ObjectReader>(
148    git: &G,
149    head: &str,
150    view: &DiffView,
151    symbols: &SymbolReaders,
152) -> Result<HashMap<usize, FileSymbols>, EngineError> {
153    let wanted: Vec<usize> = view
154        .files
155        .iter()
156        .enumerate()
157        .filter(|(_, f)| {
158            f.generated.is_none()
159                && !f.binary
160                && f.submodule.is_none()
161                && f.hunks.iter().any(|&hi| !view.hunks[hi].added.is_empty())
162        })
163        .map(|(fi, _)| fi)
164        .collect();
165
166    let specs: Vec<(&str, &[u8])> = wanted
167        .iter()
168        .map(|&fi| (head, view.files[fi].path.as_slice()))
169        .collect();
170
171    Ok(wanted
172        .iter()
173        .copied()
174        .zip(git.blobs(&specs)?)
175        .filter_map(|(fi, blob)| {
176            let path = view.files[fi].path.as_slice();
177            let content = blob?;
178            Some((fi, symbols.of_file(path, &content)?))
179        })
180        .collect())
181}
182
183/// Symbols reach the schema as text. They are identifiers by construction, so
184/// this is the display boundary and lossy conversion is the honest answer to
185/// bytes that are not.
186fn text(sym: &[u8]) -> String {
187    String::from_utf8_lossy(sym).into_owned()
188}