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}