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::sites;
23use super::symbols::{FileSymbols, Scope, Symbol, SymbolReaders};
24use crate::EngineError;
25use crate::model::DiffView;
26use crate::ports::ObjectReader;
27use crate::schema;
28use crate::shape::Partition;
29
30/// What each class introduces, and which classes it consumes. Both indexed by
31/// class index, parallel to `Partition::classes`.
32pub struct ClassGraph {
33    pub defines: Vec<Vec<String>>,
34    pub depends_on: Vec<Vec<schema::ClassEdge>>,
35    /// The same extraction, one class apart: which token resolves to which
36    /// declaration ([`super::sites`]). Read by consumers that SHOW a
37    /// dependency; never by the ordering stage.
38    pub symbols: schema::SymbolIndex,
39}
40
41/// What a name is compared within.
42///
43/// A global name is compared within its namespace — the body of names the
44/// reader says it belongs to. `Widget` read from one Rust file is the same
45/// `Widget` read from another, and is NOT the `Widget` in a TypeScript file:
46/// nothing here parses a monorepo's build graph, so a name shared across two
47/// languages is a coincidence the tool cannot tell from a fact (ADR 0031).
48///
49/// A file-local name is compared within its file, which is narrower than any
50/// namespace — `label` in one file and `label` in another are two symbols, and
51/// neither can draw an edge to the other's class.
52#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
53enum Namespace {
54    Language(Vec<u8>),
55    File(usize),
56}
57
58type Key = (Namespace, Vec<u8>);
59
60fn key(file: usize, namespace: &[u8], symbol: &Symbol) -> Key {
61    let within = match symbol.scope {
62        Scope::Global => Namespace::Language(namespace.to_vec()),
63        Scope::File => Namespace::File(file),
64    };
65    (within, symbol.name.clone())
66}
67
68/// Build the graph over the **added** lines of every class: what the change
69/// introduces, and what the changed code now calls.
70///
71/// Hunks in generated files contribute no symbols. A lockfile would otherwise
72/// appear to define half the dependency tree. This is classification, never
73/// enumeration — the class, its hunks and its files all still exist
74/// (ADR 0005/0012).
75pub fn build<G: ObjectReader>(
76    git: &G,
77    head: &str,
78    view: &DiffView,
79    partition: &Partition,
80    symbols: &SymbolReaders,
81) -> Result<ClassGraph, EngineError> {
82    let parsed = parse_files(git, head, view, symbols)?;
83
84    let n = partition.classes.len();
85    let mut defs: Vec<BTreeSet<Key>> = vec![BTreeSet::new(); n];
86    let mut refs: Vec<BTreeSet<Key>> = vec![BTreeSet::new(); n];
87    // Which class wrote each added line, so a declaration ON one can name it.
88    // A declaration the change did not write has no class, and says so.
89    let mut line_class: HashMap<(usize, u32), usize> = HashMap::new();
90
91    for (ci, file, fs, line) in written_lines(view, partition, &parsed) {
92        let at = |s: &Symbol| key(file, &fs.namespace, s);
93        defs[ci].extend(fs.defines_at(line).iter().map(at));
94        refs[ci].extend(fs.references_at(line).iter().map(at));
95        line_class.insert((file, line), ci);
96    }
97
98    // Only symbols defined by exactly ONE class create edges. A symbol two
99    // classes define is ambiguous, and this heuristic cannot say which one a
100    // reference meant; a precise `Language` (ADR 0015) would resolve it
101    // instead of dropping it.
102    //
103    // A key carries the namespace it is compared within, so the ambiguity is
104    // judged there too: two files each declaring `label` locally are not a
105    // clash, one file declaring it twice is, and a Rust `Widget` and a
106    // TypeScript one never meet to clash at all.
107    let mut definer: HashMap<&Key, Option<usize>> = HashMap::new();
108    for (ci, d) in defs.iter().enumerate() {
109        for sym in d {
110            definer
111                .entry(sym)
112                .and_modify(|e| *e = None)
113                .or_insert(Some(ci));
114        }
115    }
116
117    let mut depends_on: Vec<Vec<schema::ClassEdge>> = Vec::with_capacity(n);
118    for (ci, r) in refs.iter().enumerate() {
119        // BTreeMap keyed by the defining class index: edges come out sorted by
120        // class number, which is `C0`, `C1`, … in the ids too.
121        let mut by_target: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
122        for sym in r {
123            if let Some(&Some(def_ci)) = definer.get(sym)
124                && def_ci != ci
125            {
126                by_target.entry(def_ci).or_default().insert(text(&sym.1));
127            }
128        }
129        depends_on.push(
130            by_target
131                .into_iter()
132                .map(|(target, via)| schema::ClassEdge {
133                    on: format!("C{target}"),
134                    via: via.into_iter().collect(),
135                })
136                .collect(),
137        );
138    }
139
140    // The index, from the same parse and the same verdict. A definition whose
141    // key has no unique definer is dropped here, so it is absent from the index
142    // for exactly the reason it draws no edge.
143    // **Every declaration in a parsed file, not only the ones the change wrote.**
144    //
145    // The graph above reads added lines because it asks what the CHANGE
146    // introduces. The index is asked a different question — "what is this name
147    // on the line in front of me" — and the commonest shape of that question is
148    // a new call to a helper that was already there. Answering it needs the
149    // declaration wherever it sits.
150    //
151    // It costs nothing the graph can see: `defs` is untouched above, so edges,
152    // the single-definer rule and the corpus figures are exactly as they were.
153    let mut parsed_files: Vec<&usize> = parsed.keys().collect();
154    parsed_files.sort();
155    let mut declared: Vec<(Key, sites::Definition)> = Vec::new();
156    for &fi in &parsed_files {
157        let fs = &parsed[fi];
158        for (i, row) in fs.defines.iter().enumerate() {
159            let line = i as u32 + 1;
160            for sym in row {
161                declared.push((
162                    key(*fi, &fs.namespace, sym),
163                    sites::Definition {
164                        name: sym.name.clone(),
165                        file: *fi,
166                        line,
167                        site: sym.site,
168                        // `None` where the change did not write this line: the
169                        // declaration is real, it is simply not part of the
170                        // change, and claiming a class for it would be a lie.
171                        class: line_class.get(&(*fi, line)).copied(),
172                    },
173                ));
174            }
175        }
176    }
177
178    // The index's OWN single-definer rule, over that wider set. Same rule as
179    // the graph's and a different population, so it has to be computed here:
180    // a name the change declares once but the file declares twice is ambiguous
181    // to a reader even though it is unambiguous to the graph.
182    let mut index_definer: HashMap<&Key, Option<usize>> = HashMap::new();
183    for (n, (k, _)) in declared.iter().enumerate() {
184        index_definer
185            .entry(k)
186            .and_modify(|e| *e = None)
187            .or_insert(Some(n));
188    }
189
190    let mut definitions: Vec<sites::Definition> = Vec::new();
191    let mut of_key: HashMap<&Key, usize> = HashMap::new();
192    for (k, d) in &declared {
193        if !matches!(index_definer.get(k), Some(Some(_))) {
194            continue;
195        }
196        // One entry per NAME. A query can capture one declaration twice, and
197        // two ids for one declaration would step a reader through the same
198        // snippet twice.
199        if of_key.contains_key(k) {
200            continue;
201        }
202        of_key.insert(k, definitions.len());
203        definitions.push(sites::Definition {
204            name: d.name.clone(),
205            file: d.file,
206            line: d.line,
207            site: d.site,
208            class: d.class,
209        });
210    }
211
212    // **Uses come from the lines the change WROTE, and only those.**
213    //
214    // Those are the lines the reviewer is reading, and they are what bounds
215    // this: recording every mention in every parsed file would make the index
216    // grow with the SIZE OF THE FILES rather than with the size of the change,
217    // now that any declaration can be resolved against.
218    //
219    // The cost is that a reader who opens context and lands on an older call
220    // site gets nothing there. That was the author's call, and it is the right
221    // way round: the change is the thing being read.
222    let mut uses: Vec<sites::Use> = Vec::new();
223    for (_, file, fs, line) in written_lines(view, partition, &parsed) {
224        for sym in fs.references_at(line) {
225            let k = key(file, &fs.namespace, sym);
226            let Some(&def) = of_key.get(&k) else { continue };
227            // A declaration is not a use of itself. The crude reader
228            // has no veto — its reference regex takes every identifier
229            // on a line, the name it just declared included — so
230            // `fn helper()` reports `helper` as reading `helper`.
231            // Pointing a reader at the line they are standing on is the
232            // one answer never worth giving.
233            //
234            // Position, not name: the same name genuinely used again on
235            // its own declaring line — a default argument, a one-line
236            // recursive call — is a real use and stays.
237            let d = &definitions[def];
238            if d.file == file && d.line == line && d.site.start == sym.site.start {
239                continue;
240            }
241            uses.push(sites::Use {
242                def,
243                file,
244                line,
245                site: sym.site,
246            });
247        }
248    }
249
250    let symbols = sites::build(definitions, uses);
251
252    Ok(ClassGraph {
253        symbols,
254        defines: defs
255            .iter()
256            // A class can define one name globally and another locally, and
257            // could in principle define the same spelling both ways. The set
258            // is over the printed name, so the list stays one entry per name.
259            .map(|d| {
260                d.iter()
261                    .map(|k| text(&k.1))
262                    .collect::<BTreeSet<String>>()
263                    .into_iter()
264                    .collect()
265            })
266            .collect(),
267        depends_on,
268    })
269}
270
271/// Every line the change WROTE, with the symbols read from its file: `(class,
272/// file, symbols, line)`, class by class and hunk by hunk. Both passes over the
273/// diff walk exactly these lines, and used to walk them in their own words.
274///
275/// Two kinds of file contribute nothing, and each for its own reason.
276/// Generated content defines nothing — a lockfile would otherwise appear to
277/// define half the dependency tree. A gitlink's only added line is
278/// `Subproject commit <oid>`: diff prose about a commit this repository does
279/// not have, whose words are plausible identifiers.
280///
281/// Both skips belong HERE rather than only in `parse_files`. A category
282/// excluded from the blob read still reaches the fallback, which is how the
283/// gitlink's prose used to become references.
284///
285/// No entry in `parsed` means no reader claimed the file, or none could read
286/// it. Either way the class gains no symbols from this hunk: the domain never
287/// substitutes one reader's answer for another's, and never invents one of its
288/// own.
289fn written_lines<'a>(
290    view: &'a DiffView,
291    partition: &'a Partition,
292    parsed: &'a HashMap<usize, FileSymbols>,
293) -> impl Iterator<Item = (usize, usize, &'a FileSymbols, u32)> + 'a {
294    partition
295        .classes
296        .iter()
297        .enumerate()
298        .flat_map(move |(ci, members)| {
299            members.iter().flat_map(move |&hi| {
300                let h = &view.hunks[hi];
301                let file = view.file_of(h);
302                let skipped = file.generated.is_some() || file.submodule.is_some();
303                let fs = if skipped { None } else { parsed.get(&h.file) };
304                fs.into_iter().flat_map(move |fs| {
305                    (0..h.added.len() as u32).map(move |i| (ci, h.file, fs, h.new_start + i))
306                })
307            })
308        })
309}
310
311/// Parse every file that can contribute a symbol, once — keyed by file index.
312///
313/// **Whole files, from the head tree.** The hooks used to see one diff line at
314/// a time, which cannot tell a line inside a block comment from code. So the
315/// content comes from the odb and the hunks say which of its lines to read.
316///
317/// One bulk read for the lot: a blob costs a process and a process costs
318/// milliseconds (ADR 0021). A file that can contribute nothing is never read —
319/// generated content defines nothing (a lockfile would otherwise appear to
320/// define half the dependency tree), a binary carries no lines, and a file
321/// whose every hunk is a pure deletion has no added line to attribute.
322///
323/// A gitlink is excluded twice over: there is no blob behind the path, so asking
324/// for one is an error rather than an absence, and `build` skips it outright so
325/// its pseudo-hunk never reaches the fallback either.
326fn parse_files<G: ObjectReader>(
327    git: &G,
328    head: &str,
329    view: &DiffView,
330    symbols: &SymbolReaders,
331) -> Result<HashMap<usize, FileSymbols>, EngineError> {
332    let wanted: Vec<usize> = view
333        .files
334        .iter()
335        .enumerate()
336        .filter(|(_, f)| {
337            f.generated.is_none()
338                && !f.binary
339                && f.submodule.is_none()
340                && f.hunks.iter().any(|&hi| !view.hunks[hi].added.is_empty())
341        })
342        .map(|(fi, _)| fi)
343        .collect();
344
345    let specs: Vec<(&str, &[u8])> = wanted
346        .iter()
347        .map(|&fi| (head, view.files[fi].path.as_slice()))
348        .collect();
349
350    Ok(wanted
351        .iter()
352        .copied()
353        .zip(git.blobs(&specs)?)
354        .filter_map(|(fi, blob)| {
355            let path = view.files[fi].path.as_slice();
356            let content = blob?;
357            Some((fi, symbols.of_file(path, &content)?))
358        })
359        .collect())
360}
361
362/// Symbols reach the schema as text. They are identifiers by construction, so
363/// this is the display boundary and lossy conversion is the honest answer to
364/// bytes that are not.
365fn text(sym: &[u8]) -> String {
366    String::from_utf8_lossy(sym).into_owned()
367}