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