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}