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}