Skip to main content

devscout_rs/query/
impact.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::graph;
4
5use super::dispatch::{implemented_interfaces, inbound_walk_kinds};
6use super::impact_why::{self, Why, WhyTrack};
7use super::index::{def_files, GraphIndex};
8use super::infra::is_infra_file;
9use super::member::{self, MemberCandidate, MemberSeedResolution};
10use super::rank::{personalized_page_rank, DEFAULT_DAMPING, DEFAULT_ITERATIONS};
11use super::refs_tables::{cap_rows, edge_loc, row_tier};
12use super::seq::{SeqMap, SeqSet};
13use super::symbol::{resolve_symbol, Resolution};
14
15/// Default number of hops for the impact walk.
16pub const DEFAULT_HOPS: u32 = 2;
17
18/// Default fan-in brake: an interface injected into more than this many
19/// distinct constructors is treated as infrastructure for widening purposes,
20/// whatever it is called. Chosen from the constructor-injection in-degree
21/// histogram of a large corpus, where 151 of 155 injected contracts (97.4%)
22/// sit at or below 8 and nothing sits between 8 and the four estate-wide ones
23/// (9, 11, 12, 21). `0` disables the brake (`--iface-max-fanin 0`).
24pub const DEFAULT_IFACE_MAX_FANIN: usize = 8;
25// ============================================================================
26// impact_walk + personalized_page_rank.
27// ============================================================================
28
29/// One representative referencing line PER EDGE KIND that reached this file,
30/// the lowest line per kind. `0` means "this kind never contributed", which
31/// keeps a row a kind never touched free of that key in `--json`. `direct_amb`
32/// is the ambiguous half of the `direct` kind, kept apart only so
33/// `build_impact_model` can apply the resolved-over-ambiguous tie-break instead
34/// of letting an ambiguous line win by being numerically smaller.
35#[derive(Debug, Clone, Default, PartialEq)]
36pub struct KindLines {
37    /// The direct value.
38    pub direct: usize,
39    /// The direct amb value.
40    pub direct_amb: usize,
41    /// The ctor di value.
42    pub ctor_di: usize,
43    /// The heuristic value.
44    pub heuristic: usize,
45    /// The iface value.
46    pub iface: usize,
47}
48
49// The lowest-line-wins guard for `ctor_di`, whose `why` is always `ctor-di`;
50// every other `KindLines` slot routes through `Hit`'s `note_*` methods instead.
51fn note_line(slot: &mut usize, line: usize) {
52    if line > 0 && (*slot == 0 || line < *slot) {
53        *slot = line;
54    }
55}
56
57// `pub(super)` throughout: `impact_why::build_visited_entry` reads every
58// field of a finished `Hit` to assemble one `VisitedEntry`.
59#[derive(Debug, Clone, Default)]
60pub(super) struct Hit {
61    pub(super) via_count: u32,
62    pub(super) ambiguous_count: u32,
63    pub(super) heuristic_count: u32,
64    // How many of `heuristic_count` came from the EXTENSION tier. Counted
65    // rather than flagged so the walk keeps one shape for both tiers, and one
66    // is all the row needs to call itself an extension (see `row_tier`).
67    pub(super) ext_count: u32,
68    pub(super) symbols: SeqSet<String>,
69    // The `via` labels an interface-hop hit at this file carries
70    // (`"IFoo (ctor-di)"` or bare `"IFoo"`), first-seen order.
71    pub(super) iface_via: SeqSet<String>,
72    // One representative line per edge kind (see `KindLines`).
73    pub(super) lines: KindLines,
74    // Which edge explains each of `lines`' slots -- see `impact_why::WhyTrack`.
75    pub(super) why_track: WhyTrack,
76}
77
78/// One visited file's entry in an impact walk.
79#[derive(Debug, Clone, PartialEq)]
80pub struct VisitedEntry {
81    /// The hop value.
82    pub hop: u32,
83    /// The via count value.
84    pub via_count: u32,
85    /// The ambiguous count value.
86    pub ambiguous_count: u32,
87    /// The heuristic count value.
88    pub heuristic_count: u32,
89    /// How many of `heuristic_count` came from the extension tier.
90    pub ext_count: u32,
91    /// The symbols value.
92    pub symbols: Vec<String>,
93    /// The interface `via` labels for this file's hits.
94    pub iface_via: Vec<String>,
95    /// One representative line per edge kind that reached this file.
96    pub lines: KindLines,
97    /// This file is a hub, so the walk recorded it as an affected file and
98    /// stopped there instead of expanding through it.
99    pub infra: bool,
100    /// Why this file was reached, folded across every kind that fired.
101    pub why: Why,
102}
103
104/// An interface the walk refused to widen through, and how many distinct
105/// constructors inject it. Sorted widest-first then by name, a total order that
106/// depends on nothing about the walk's own iteration.
107#[derive(Debug, Clone, PartialEq)]
108pub struct BrakedIface {
109    /// The iface value.
110    pub iface: String,
111    /// The fanin value.
112    pub fanin: usize,
113}
114
115/// A hub file the walk refused to widen THROUGH, and how many distinct other
116/// files reference it. Kept in its own vec (separate from [`BrakedIface`]) so
117/// that type, its tests and its JSON bytes are untouched when the file brake
118/// never fires. Sorted widest-first then by path, the same total order, for the
119/// same reason.
120#[derive(Debug, Clone, PartialEq)]
121pub struct BrakedFile {
122    /// The file value.
123    pub file: String,
124    /// The indegree value.
125    pub indegree: usize,
126}
127
128/// The result of an impact walk.
129pub struct ImpactWalkResult {
130    /// Visited files in discovery order across hops (the first hop a file is
131    /// hit wins its slot). `build_impact_model` reads these keys in order to
132    /// build the PPR `nodes` array, so this order is directly
133    /// float-accumulation-order-visible downstream (see module header).
134    pub visited: SeqMap<VisitedEntry>,
135    /// The file-level subgraph in the blast-radius direction. A plain
136    /// `HashMap<_, HashSet<_>>` because -- unlike `visited`/`frontier` -- an
137    /// out-edge set's iteration order does NOT affect
138    /// `personalized_page_rank`'s float accumulation (each target in one node's
139    /// out-set gets exactly one independent `+=`), so no ordering wrapper is
140    /// needed here.
141    pub fwd_adj: HashMap<String, HashSet<String>>,
142    /// The seed files, in `seed_ids` order flat-mapped through `def_files`.
143    pub seed_files: SeqSet<String>,
144    /// The interfaces the walk refused to widen through.
145    pub braked: Vec<BrakedIface>,
146    /// The hub files the walk refused to widen through.
147    pub braked_files: Vec<BrakedFile>,
148}
149
150fn add_adj(fwd_adj: &mut HashMap<String, HashSet<String>>, from: &str, to: &str) {
151    fwd_adj
152        .entry(from.to_string())
153        .or_default()
154        .insert(to.to_string());
155}
156
157/// Reverse-edge k-hop walk from a set of seed def ids. Hop N's frontier is
158/// every def declared in a file discovered at hop N-1; a file already visited
159/// (or a seed's own file) is recorded at most once, at its first (minimum) hop.
160/// Ambiguous edges whose candidates include a frontier def count toward
161/// via-count/top-symbols too. As a byproduct it builds `fwd_adj`, the
162/// file-level subgraph that seeds the PPR ranking pass.
163///
164/// `iface` (the CLI's `--no-iface`, inverted) gates the interface hop: it is
165/// def-id-matched, never name-matched, so `infra`/`ambiguous` ctor-di edges can
166/// never widen anything. `iface_max_fanin` brakes the same hop for a BROAD
167/// interface: one whose ctor-di in-degree (`ctor_di_fanin`, distinct
168/// constructor sites) exceeds the threshold is treated as infrastructure for
169/// widening purposes, whatever it is named. Both widening paths are braked
170/// together -- a contract injected everywhere is also NAMED everywhere, so
171/// braking one alone leaves the radius just as wide. `0` disables the brake and
172/// restores unbraked widening.
173#[allow(
174    clippy::too_many_lines,
175    clippy::cognitive_complexity,
176    reason = "one breadth-first walk with both widening brakes inline; the brakes only work if they see the same frontier the walk itself sees"
177)]
178pub fn impact_walk(
179    index: &GraphIndex,
180    seed_ids: &[String],
181    hops: u32,
182    iface: bool,
183    iface_max_fanin: usize,
184    hub_max_indegree: usize,
185) -> ImpactWalkResult {
186    let mut visited: SeqMap<VisitedEntry> = SeqMap::new();
187    // Interface name -> its fan-in, for every interface this walk actually
188    // refused to widen through. Recorded so the narrowing is REPORTED, never
189    // silent.
190    let mut braked: HashMap<String, usize> = HashMap::new();
191    // File -> its in-degree, for every hub file this walk refused to widen
192    // THROUGH. Recorded on the same terms as `braked` above.
193    let mut braked_files: HashMap<String, usize> = HashMap::new();
194    let mut seed_files: SeqSet<String> = SeqSet::new();
195    for id in seed_ids {
196        for f in def_files(index, id) {
197            seed_files.insert(f);
198        }
199    }
200
201    let mut fwd_adj: HashMap<String, HashSet<String>> = HashMap::new();
202
203    let mut frontier: SeqSet<String> = SeqSet::new();
204    for id in seed_ids {
205        frontier.insert(id.clone());
206    }
207    let mut seen_defs: HashSet<String> = seed_ids.iter().cloned().collect();
208
209    let mut hop = 1u32;
210    while hop <= hops && frontier.len() > 0 {
211        let mut hits: SeqMap<Hit> = SeqMap::new();
212
213        for def_id in frontier.iter() {
214            if let Some(inb) = index.inbound.get(def_id) {
215                for kind_edges in inbound_walk_kinds(inb) {
216                    for &ei in kind_edges {
217                        let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
218                        let from_file = loc_file.to_string();
219                        {
220                            let h = hits.get_or_insert_default(&from_file);
221                            h.via_count += 1;
222                            h.symbols.insert(def_id.clone());
223                            h.note_direct(loc_line, &index.graph.edges[ei]);
224                        }
225                        for sf in def_files(index, def_id) {
226                            add_adj(&mut fwd_adj, &sf, &from_file);
227                        }
228                    }
229                }
230            }
231            if let Some(amb_idxs) = index.ambiguous_by_candidate.get(def_id) {
232                for &ei in amb_idxs {
233                    let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
234                    let from_file = loc_file.to_string();
235                    {
236                        let h = hits.get_or_insert_default(&from_file);
237                        h.ambiguous_count += 1;
238                        h.note_direct_amb(loc_line, &index.graph.edges[ei]);
239                    }
240                    for sf in def_files(index, def_id) {
241                        add_adj(&mut fwd_adj, &sf, &from_file);
242                    }
243                }
244            }
245            // The interface hop. `seen_sites` dedupes a ctor-injected
246            // parameter's own COMPANION plain `uses-type` ref (every ctor-param
247            // ref emits one, resolving to the INTERFACE's own def at the
248            // identical from_file/from_line) against the direct-reference pass
249            // just below, so a ctor-injecting consumer is counted once,
250            // labelled via ctor-di, never twice.
251            if iface {
252                let mut seen_sites: HashSet<(String, usize)> = HashSet::new();
253                if let Some(ctor_idxs) = index.ctor_di_by_to.get(def_id) {
254                    for &ei in ctor_idxs {
255                        let graph::Edge::CtorDi {
256                            from_file,
257                            from_line,
258                            iface: iface_name,
259                            ..
260                        } = &index.graph.edges[ei]
261                        else {
262                            continue;
263                        };
264                        // A braked edge is skipped WHOLE: its site never enters
265                        // `seen_sites` either. It does not need to; the same
266                        // interface is braked below too.
267                        if brake_fanin(index, iface_max_fanin, iface_name, &mut braked) {
268                            continue;
269                        }
270                        let from_file = from_file.clone();
271                        {
272                            let h = hits.get_or_insert_default(&from_file);
273                            h.via_count += 1;
274                            h.symbols.insert(def_id.clone());
275                            h.iface_via.insert(format!("{iface_name} (ctor-di)"));
276                            note_line(&mut h.lines.ctor_di, *from_line);
277                        }
278                        seen_sites.insert((from_file.clone(), *from_line));
279                        for sf in def_files(index, def_id) {
280                            add_adj(&mut fwd_adj, &sf, &from_file);
281                        }
282                    }
283                }
284                for iface_id in implemented_interfaces(index, def_id) {
285                    let Some(iface_inb) = index.inbound.get(&iface_id) else {
286                        continue;
287                    };
288                    let iface_name = index
289                        .def(&iface_id)
290                        .map(|d| d.name.clone())
291                        .unwrap_or_else(|| iface_id.clone());
292                    if brake_fanin(index, iface_max_fanin, &iface_name, &mut braked) {
293                        continue;
294                    }
295                    // 'inherits' is deliberately excluded here: a SIBLING
296                    // implementor of the same interface owns its own
297                    // base-list edge to it, which is a fact about the
298                    // sibling, never a reference to THIS class.
299                    for kind_edges in [&iface_inb.uses_type, &iface_inb.uses_member] {
300                        for &ei in kind_edges {
301                            let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
302                            let site = (loc_file.to_string(), loc_line);
303                            if seen_sites.contains(&site) {
304                                continue;
305                            }
306                            let from_file = site.0.clone();
307                            seen_sites.insert(site);
308                            {
309                                let h = hits.get_or_insert_default(&from_file);
310                                h.via_count += 1;
311                                h.symbols.insert(def_id.clone());
312                                h.iface_via.insert(iface_name.clone());
313                                h.note_iface(loc_line, &index.graph.edges[ei]);
314                            }
315                            for sf in def_files(index, def_id) {
316                                add_adj(&mut fwd_adj, &sf, &from_file);
317                            }
318                        }
319                    }
320                }
321            }
322            // A heuristic edge REACHES a file and stops there.
323            // It never calls `add_adj` (so the PageRank graph, and therefore
324            // every precise row's score and ordering, is bit-for-bit what it
325            // was) and its file never enters `next_frontier` below, so the walk
326            // cannot continue THROUGH a guess. One guess is a guess; a guess
327            // used as the premise of the next hop is compound fiction, and
328            // blast radius is exactly the answer people act on.
329            if let Some(hinb) = index.heuristic_inbound.get(def_id) {
330                for kind_edges in [&hinb.inherits, &hinb.uses_type, &hinb.uses_member] {
331                    for &ei in kind_edges {
332                        let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
333                        let from_file = loc_file.to_string();
334                        let h = hits.get_or_insert_default(&from_file);
335                        h.heuristic_count += 1;
336                        if index.graph.edges[ei].tier() == Some(graph::HeuristicTier::Ext) {
337                            h.ext_count += 1;
338                        }
339                        h.symbols.insert(def_id.clone());
340                        h.note_heuristic(loc_line, &index.graph.edges[ei]);
341                    }
342                }
343            }
344        }
345
346        let mut next_frontier: SeqSet<String> = SeqSet::new();
347        for (file, h) in hits.iter() {
348            // A hub is a file the rest of the estate refers to BY JOB rather
349            // than by dependency. Two independent halves: the path-pattern
350            // classification, which is always on, and the in-degree threshold,
351            // which `0` disables. A hub file is still an AFFECTED file: it is
352            // recorded as a row like any other, carrying `class: infra` so the
353            // narrowing is visible on the row itself and not only in the
354            // trailer.
355            let indegree = index.hub_indegree.get(file).copied().unwrap_or(0);
356            let hub = is_infra_file(file) || (hub_max_indegree > 0 && indegree >= hub_max_indegree);
357            if !visited.contains_key(file) && !seed_files.contains(file) {
358                visited.insert(file.clone(), impact_why::build_visited_entry(hop, hub, h));
359            }
360            // Reached ONLY by heuristic edges: recorded above as an affected
361            // file, never expanded. A file with even one precise or ambiguous
362            // hit expands exactly as it always did.
363            if h.via_count == 0 && h.ambiguous_count == 0 {
364                continue;
365            }
366            // The brake, at the SAME point in the walk a broad interface is
367            // excluded: the hop that produced this file still fired, and every
368            // interface path still fired. What stops here is expansion THROUGH
369            // the hub. Reported only when the walk was actually going to expand
370            // (`hop < hops`); on the last hop nothing expands, so nothing was
371            // held back and a trailer naming this file would claim a narrowing
372            // that never happened.
373            if hub {
374                if hop < hops {
375                    braked_files.insert(file.clone(), indegree);
376                }
377                continue;
378            }
379            if let Some(def_idxs) = index.by_file.get(file) {
380                for &di in def_idxs {
381                    let def_id = &index.graph.defs[di].id;
382                    if !seen_defs.contains(def_id) {
383                        seen_defs.insert(def_id.clone());
384                        next_frontier.insert(def_id.clone());
385                    }
386                }
387            }
388        }
389        frontier = next_frontier;
390        hop += 1;
391    }
392
393    // Sorted widest-first, then by name -- a total order that depends on
394    // nothing about the walk's own iteration, so the output is deterministic
395    // here without needing an ordered set.
396    let mut braked: Vec<BrakedIface> = braked
397        .into_iter()
398        .map(|(iface, fanin)| BrakedIface { iface, fanin })
399        .collect();
400    braked.sort_by(|a, b| b.fanin.cmp(&a.fanin).then_with(|| a.iface.cmp(&b.iface)));
401    let mut braked_files: Vec<BrakedFile> = braked_files
402        .into_iter()
403        .map(|(file, indegree)| BrakedFile { file, indegree })
404        .collect();
405    braked_files.sort_by(|a, b| {
406        b.indegree
407            .cmp(&a.indegree)
408            .then_with(|| a.file.cmp(&b.file))
409    });
410
411    ImpactWalkResult {
412        visited,
413        fwd_adj,
414        seed_files,
415        braked,
416        braked_files,
417    }
418}
419
420// Returns true when this injected type is braked, recording it on the way out
421// -- the single point of decision, so no widening path can skip an interface
422// without also declaring it.
423fn brake_fanin(
424    index: &GraphIndex,
425    iface_max_fanin: usize,
426    name: &str,
427    braked: &mut HashMap<String, usize>,
428) -> bool {
429    if iface_max_fanin == 0 {
430        return false;
431    }
432    let fanin = index.ctor_di_fanin.get(name).copied().unwrap_or(0);
433    if fanin <= iface_max_fanin {
434        return false;
435    }
436    braked.insert(name.to_string(), fanin);
437    true
438}
439// ============================================================================
440// resolve_impact_seed + build_impact_model.
441// ============================================================================
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444/// Represents `SeedKind`.
445pub enum SeedKind {
446    /// Represents `File`.
447    File,
448    /// Represents `Symbol`.
449    Symbol,
450}
451
452/// Whether `arg` looks like a file path rather than a symbol query: a bare
453/// simple/qualified name with no path separator or file extension is a symbol
454/// query; anything else is a file path. Equivalent to `/\.[A-Za-z0-9]+$/` by
455/// checking the LAST `.` only (if an earlier dot's suffix were all-alnum, the
456/// last dot's suffix -- which contains that earlier dot -- could not be, since
457/// `.` is not `[A-Za-z0-9]`). Note a fully-qualified symbol id like
458/// `App.Widgets.IWidget` (trailing segment alphanumeric) DOES look like a file
459/// path under this heuristic.
460pub fn looks_like_file_path(arg: &str) -> bool {
461    if arg.contains('/') {
462        return true;
463    }
464    match arg.rfind('.') {
465        Some(pos) => {
466            let ext = &arg[pos + 1..];
467            !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
468        }
469        None => false,
470    }
471}
472
473#[derive(Debug, Clone, PartialEq)]
474/// Represents `SeedResolution`.
475pub enum SeedResolution {
476    /// The value value.
477    Resolved {
478        /// The resolved seed kind.
479        kind: SeedKind,
480        /// The resolved definition identifiers.
481        ids: Vec<String>,
482    },
483    /// The value value.
484    Ambiguous {
485        /// The seed kind.
486        kind: SeedKind,
487        /// The candidate definition identifiers.
488        ids: Vec<String>,
489    },
490    /// The value value.
491    NotFound {
492        /// The inferred seed kind.
493        kind: SeedKind,
494    },
495    /// A member seed whose name more than one type declares.
496    MemberAmbiguous(Vec<MemberCandidate>),
497}
498
499// The member ladder, shared by both branches below: a member seed answers as
500// its unique declaring type; `not_found_kind` is the caller's own miss kind.
501fn member_fallback(index: &GraphIndex, arg: &str, not_found_kind: SeedKind) -> SeedResolution {
502    match member::resolve_member_seed(index, arg) {
503        MemberSeedResolution::Resolved(id) => SeedResolution::Resolved {
504            kind: SeedKind::Symbol,
505            ids: vec![id],
506        },
507        MemberSeedResolution::Ambiguous(candidates) => SeedResolution::MemberAmbiguous(candidates),
508        MemberSeedResolution::NotFound => SeedResolution::NotFound {
509            kind: not_found_kind,
510        },
511    }
512}
513
514/// Resolve an impact-walk seed to a file's defs or a single symbol. The type
515/// ladder ([`resolve_symbol`]) runs first; a member seed, tried only once it
516/// has missed, answers AS its unique declaring type -- `impact` has no
517/// member-shaped answer of its own. A dotted member seed looks like a file
518/// path under [`looks_like_file_path`], so it is tried on a file-path MISS
519/// instead: that branch never resolved a type before, so nothing changes.
520pub fn resolve_impact_seed(index: &GraphIndex, arg: &str) -> SeedResolution {
521    if looks_like_file_path(arg) {
522        return match index.by_file.get(arg) {
523            Some(ids) if !ids.is_empty() => SeedResolution::Resolved {
524                kind: SeedKind::File,
525                ids: ids
526                    .iter()
527                    .map(|&i| index.graph.defs[i].id.clone())
528                    .collect(),
529            },
530            _ => member_fallback(index, arg, SeedKind::File),
531        };
532    }
533    match resolve_symbol(index, arg) {
534        Resolution::Resolved(id) => SeedResolution::Resolved {
535            kind: SeedKind::Symbol,
536            ids: vec![id],
537        },
538        Resolution::Ambiguous(ids) => SeedResolution::Ambiguous {
539            kind: SeedKind::Symbol,
540            ids,
541        },
542        Resolution::NotFound => member_fallback(index, arg, SeedKind::Symbol),
543    }
544}
545
546/// One row of an impact model: a file in the blast radius and its metrics.
547#[derive(Debug, Clone, PartialEq)]
548pub struct ImpactRow {
549    /// The file value.
550    pub file: String,
551    /// The hop value.
552    pub hop: u32,
553    /// The via count value.
554    pub via_count: u32,
555    /// The ambiguous count value.
556    pub ambiguous_count: u32,
557    /// The top symbols value.
558    pub top_symbols: Vec<String>,
559    /// The top symbols more value.
560    pub top_symbols_more: usize,
561    /// The score value.
562    pub score: f64,
563    /// "heuristic" is a property of how the file was REACHED, not of the edges
564    /// that reached it: one precise or ambiguous hit makes the file an ordinary
565    /// affected file no matter how many guesses also point at it. Only a file
566    /// reached EXCLUSIVELY by guesses is flagged. On a precise row neither this
567    /// nor `heuristic` is emitted in `--json`.
568    pub heuristic_count: u32,
569    /// The heuristic value.
570    pub heuristic: bool,
571    /// Which heuristic tier REACHED this file. Folded the same way `heuristic`
572    /// itself is -- over every guess that landed here -- so a file one
573    /// extension edge and ten name guesses all point at is an extension row.
574    /// Absent from `--json` on a precise row, like `heuristic`.
575    pub tier: Option<graph::HeuristicTier>,
576    /// Present only on a row the interface hop actually reached, empty (and
577    /// omitted from `--json`) on every other row.
578    pub iface_via: Vec<String>,
579    /// One representative referencing line per EDGE KIND that actually reached
580    /// this file, in the fixed order `direct, ctor-di, heuristic, iface`; a
581    /// kind that never contributed is absent, and a row no kind could attribute
582    /// a line to carries no entry at all. The file is the row's own `file` --
583    /// every edge folded into one row is an edge OUT OF that file -- so a line
584    /// alone locates the site.
585    pub from_lines: Vec<(&'static str, usize)>,
586    /// Set only on a hub file. The row is still an affected file; this says the
587    /// walk stopped THERE rather than continuing through it. `false` means the
588    /// key is absent in `--json`.
589    pub infra: bool,
590    /// Why this file was reached, carried unchanged from `VisitedEntry`.
591    pub why: Why,
592}
593
594/// The resolved `impact` result for one seed.
595#[derive(Debug, Clone, PartialEq)]
596pub struct ImpactModel {
597    /// The kind value.
598    pub kind: SeedKind,
599    /// The seed files value.
600    pub seed_files: Vec<String>,
601    /// The hops value.
602    pub hops: u32,
603    /// The total affected value.
604    pub total_affected: usize,
605    /// The rows value.
606    pub rows: Vec<ImpactRow>,
607    /// The dropped value.
608    pub dropped: usize,
609    /// The manifest gap value.
610    pub manifest_gap: usize,
611    /// Number of files reached exclusively by guesses. Counted over EVERY row
612    /// the walk found, capped or not.
613    pub heuristic_affected: usize,
614    /// How many of the PRECISELY affected files carry a test-flagged def.
615    /// Counted over every affected row, not just the capped `shown` slice, and
616    /// heuristic-only rows are excluded: "a guess also touched a test file" is
617    /// not coverage. Zero is the interesting answer -- a blast radius that
618    /// reaches no test file at all is a gap the caller can act on.
619    pub tests_affected: usize,
620    /// The braked interfaces, present only when the brake actually fired (empty
621    /// under `--iface-max-fanin 0` or `--no-iface`).
622    pub braked: Vec<BrakedIface>,
623    /// The braked hub files, reported alongside `braked`.
624    pub braked_files: Vec<BrakedFile>,
625}
626
627/// The outcome of an `impact` query: resolved, ambiguous, or not found.
628#[derive(Debug, Clone, PartialEq)]
629pub enum ImpactResult {
630    /// Represents `Resolved`.
631    Resolved(ImpactModel),
632    /// The value value.
633    Ambiguous {
634        /// The seed kind.
635        kind: SeedKind,
636        /// The candidate definition identifiers.
637        ids: Vec<String>,
638    },
639    /// The value value.
640    NotFound {
641        /// The inferred seed kind.
642        kind: SeedKind,
643    },
644    /// The seed named a member declared by more than one type.
645    MemberAmbiguous(Vec<MemberCandidate>),
646}
647
648// Assembles a row's per-kind representative lines. The resolved-over-ambiguous
649// tie-break decides the `direct` kind: a resolved site outranks an ambiguous
650// one, and the ambiguous line is used only when the resolved half never fired.
651fn from_lines_of(lines: &KindLines) -> Vec<(&'static str, usize)> {
652    let mut out: Vec<(&'static str, usize)> = Vec::new();
653    let direct = if lines.direct != 0 {
654        lines.direct
655    } else {
656        lines.direct_amb
657    };
658    if direct != 0 {
659        out.push(("direct", direct));
660    }
661    if lines.ctor_di != 0 {
662        out.push(("ctor-di", lines.ctor_di));
663    }
664    if lines.heuristic != 0 {
665        out.push(("heuristic", lines.heuristic));
666    }
667    if lines.iface != 0 {
668        out.push(("iface", lines.iface));
669    }
670    out
671}
672
673/// Blast radius for a seed, ranked. Never filters beyond the stated `cap`:
674/// `rows` is the ranked, capped view; `dropped`/`total_affected` account for
675/// every row the walk found, capped or not.
676pub fn build_impact_model(
677    index: &GraphIndex,
678    arg: &str,
679    hops: u32,
680    cap: usize,
681    iface: bool,
682    iface_max_fanin: usize,
683    hub_max_indegree: usize,
684) -> ImpactResult {
685    let (kind, seed_ids) = match resolve_impact_seed(index, arg) {
686        SeedResolution::Resolved { kind, ids } => (kind, ids),
687        SeedResolution::Ambiguous { kind, ids } => return ImpactResult::Ambiguous { kind, ids },
688        SeedResolution::NotFound { kind } => return ImpactResult::NotFound { kind },
689        SeedResolution::MemberAmbiguous(candidates) => {
690            return ImpactResult::MemberAmbiguous(candidates)
691        }
692    };
693
694    let walk = impact_walk(
695        index,
696        &seed_ids,
697        hops,
698        iface,
699        iface_max_fanin,
700        hub_max_indegree,
701    );
702
703    // `seed_files` then `visited` keys, deduped -- this order is
704    // float-accumulation-order-visible in `personalized_page_rank`.
705    let mut node_set: SeqSet<String> = SeqSet::new();
706    for f in walk.seed_files.iter() {
707        node_set.insert(f.clone());
708    }
709    for f in walk.visited.keys() {
710        node_set.insert(f.clone());
711    }
712    let nodes = node_set.into_vec();
713    let seeds: Vec<String> = walk.seed_files.iter().cloned().collect();
714
715    let rank = personalized_page_rank(
716        &nodes,
717        &walk.fwd_adj,
718        &seeds,
719        DEFAULT_DAMPING,
720        DEFAULT_ITERATIONS,
721    );
722
723    let mut rows: Vec<ImpactRow> = walk
724        .visited
725        .iter()
726        .map(|(file, h)| {
727            let names: Vec<String> = h
728                .symbols
729                .iter()
730                .map(|id| {
731                    index
732                        .def(id)
733                        .map(|d| d.name.clone())
734                        .unwrap_or_else(|| id.clone())
735                })
736                .collect();
737            let top_symbols: Vec<String> = names.iter().take(3).cloned().collect();
738            let top_symbols_more = names.len().saturating_sub(3);
739            let heuristic = h.via_count == 0 && h.ambiguous_count == 0;
740            ImpactRow {
741                file: file.clone(),
742                hop: h.hop,
743                via_count: h.via_count,
744                ambiguous_count: h.ambiguous_count,
745                top_symbols,
746                top_symbols_more,
747                score: *rank.get(file).unwrap_or(&0.0),
748                heuristic_count: if heuristic { h.heuristic_count } else { 0 },
749                heuristic,
750                tier: row_tier(heuristic, h.ext_count > 0),
751                iface_via: h.iface_via.clone(),
752                from_lines: from_lines_of(&h.lines),
753                infra: h.infra,
754                why: h.why,
755            }
756        })
757        .collect();
758
759    // Sort key: heuristic-only rows AFTER every precise row, then by descending
760    // score, then hop, then file. Heuristic-only rows sort last regardless of
761    // rank -- the CLI contract is positional, and PageRank never saw the
762    // heuristic edges, so their scores are not comparable with the precise ones
763    // in the first place.
764    rows.sort_by(|a, b| {
765        a.heuristic
766            .cmp(&b.heuristic)
767            .then_with(|| {
768                b.score
769                    .partial_cmp(&a.score)
770                    .unwrap_or(std::cmp::Ordering::Equal)
771            })
772            .then_with(|| a.hop.cmp(&b.hop))
773            .then_with(|| a.file.cmp(&b.file))
774    });
775
776    let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
777    let total_affected = rows.len() - heuristic_affected;
778    let tests_affected = rows
779        .iter()
780        .filter(|r| !r.heuristic && index.is_test_file(&r.file))
781        .count();
782    let (shown, dropped) = cap_rows(rows, cap);
783
784    ImpactResult::Resolved(ImpactModel {
785        kind,
786        seed_files: walk.seed_files.into_vec(),
787        hops,
788        total_affected,
789        rows: shown,
790        dropped,
791        manifest_gap: index.flagged_files.len(),
792        heuristic_affected,
793        tests_affected,
794        braked: walk.braked,
795        braked_files: walk.braked_files,
796    })
797}