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