Skip to main content

devscout_rs/query/
impact.rs

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