Skip to main content

devscout_rs/
query.rs

1// refs/impact query support: enum-member union, `impact_walk`, and
2// personalized PageRank. A `tests` block below pins the behavioral contract
3// with fixture cases exercising every code path.
4//
5// Design notes:
6//
7// - **Two-phase index build.** The index cannot be built in one call that
8//   also reads the graph and then hands back a struct borrowing that graph
9//   -- the result would be self-referential. So the caller reads the graph
10//   first with `graph::read_graph(root)`, which returns `None` when there is
11//   no graph, letting the caller check the `Option` before proceeding, then
12//   calls `load_graph_index(&graph, root)` here, which owns the manifest
13//   join and the index build.
14//
15// - **Manifest corrupt-JSON fail-open.** A corrupt manifest.json surfaces as
16//   `Err(ManifestError::InvalidJson)` from `manifest::read_manifest`.
17//   Panicking through a query path for an operator-error edge case (a
18//   hand-corrupted manifest) is worse than failing open, so
19//   `load_graph_index` treats `Err(_)` the same as "no manifest" -- the same
20//   fail-open-on-parse-error convention `graph::read_graph` follows. A
21//   manifest present but missing its `entries` key is handled the same way.
22//
23// - **Insertion-order-preserving scratch structures.** Several of
24//   `impact_walk`'s scratch maps/sets are NOT just membership/lookup
25//   structures -- their insertion order is directly observable in the final
26//   result: `Hit::symbols` feeds `top_symbols`'s first-3 truncation, and
27//   `visited` key order feeds the `nodes` array handed to
28//   `personalized_page_rank`, whose accumulation loop is float-addition over
29//   that exact order -- not associative, so a different iteration order can
30//   produce a bit-different (though not wrong) result.
31//   `std::collections::{HashMap,HashSet}` make no iteration-order guarantee,
32//   so this module has two tiny private order-preserving helpers,
33//   `SeqSet`/`SeqMap` (a `Vec` plus an index `HashMap`, first insertion wins
34//   the slot). They are pure algorithm scratch state, not artifact structs;
35//   `graph::Def`/`Edge`/`Graph` are reused directly from `graph.rs`.
36//
37// - **`str::cmp` ordering.** Location sorts use plain Unicode-codepoint
38//   `str::cmp` rather than locale-aware collation, the same accepted
39//   trade-off documented at `resolve.rs`'s `capped_candidates`: the two
40//   orderings coincide for every path/id shape this crate actually produces,
41//   and diverge only in a pathological mix of leading case or a
42//   `+`/`.`-adjacent tie -- flagged, not solved, no new ICU dependency.
43//
44// - **Result models.** `RefsModel`/`ImpactModel` (and their row/table
45//   sub-types) are the typed surface `render.rs` consumes. Field names are
46//   `snake_case` (e.g. `model.inbound.uses_type`). Nothing here is
47//   `Serialize` -- `--json` output byte-shape is `cli.rs`'s job, not this
48//   module's.
49
50use std::collections::{HashMap, HashSet};
51use std::path::Path;
52
53use crate::graph;
54use crate::manifest;
55use crate::suggest::kind_rank;
56
57// The three reference kinds and their fixed order. Not iterated generically
58// here -- each kind gets its own struct field (`inherits`/`uses_type`/
59// `uses_member`) throughout this module rather than a keyed collection. This
60// constant exists only as an anchor for the kind list and its order.
61#[allow(dead_code)]
62const REF_KINDS: [&str; 3] = ["inherits", "uses-type", "uses-member"];
63
64/// Default per-table row cap for the outbound and ambiguous tables. The three
65/// inbound tables share [`INBOUND_CAP`] instead.
66pub const DEFAULT_CAP: usize = 50;
67/// Default cap shared across all three inbound kinds -- one cap total, not one
68/// per table.
69pub const INBOUND_CAP: usize = 30;
70/// Default cap for the `--out` view: one cap shared across all four outbound
71/// kinds (inherits/uses-type/uses-member/imports), the mirror of
72/// [`INBOUND_CAP`]. `--all` lifts this cap.
73pub const OUTBOUND_CAP: usize = 30;
74/// Maximum length of a displayed source line, in UTF-16 code units.
75/// Truncation counts code units (see `clip_source`) so the cut is stable
76/// regardless of any astral characters on the line.
77pub const SOURCE_MAX: usize = 120;
78/// Default number of hops for the impact walk.
79pub const DEFAULT_HOPS: u32 = 2;
80
81/// Default fan-in brake: an interface injected into more than this many
82/// distinct constructors is treated as infrastructure for widening purposes,
83/// whatever it is called. Chosen from the constructor-injection in-degree
84/// histogram of a large corpus, where 151 of 155 injected contracts (97.4%)
85/// sit at or below 8 and nothing sits between 8 and the four estate-wide ones
86/// (9, 11, 12, 21). `0` disables the brake (`--iface-max-fanin 0`).
87pub const DEFAULT_IFACE_MAX_FANIN: usize = 8;
88
89/// Default hub brake: a file whose in-degree is at or above this stops
90/// widening. In-degree here is the number of DISTINCT OTHER FILES that
91/// reference a file through a `direct` (inherits/uses-type/uses-member) or
92/// heuristic edge. Derived from a corpus histogram rather than guessed: 824
93/// files carry at least one referrer, every value from 1 to 33 is populated
94/// (holding 777 of the 824, 94.3%), and the first empty slot is at 34, above
95/// which the tail is sparse and gapped. The comparison is `>=`, so 34 is the
96/// first value that is not an ordinary file. `0` disables the in-degree half
97/// of the brake; the name-pattern half ([`is_infra_file`]) is a
98/// classification, not a threshold, and stays on.
99pub const DEFAULT_HUB_MAX_INDEGREE: usize = 34;
100
101// The name-pattern half of the hub classification, extending the `infra` idea
102// from TYPE names to FILE shapes: files the rest of an estate refers to BY
103// JOB, not by dependency -- registering the container, composing the
104// application, entering the process, or holding the setup every test class in
105// a suite inherits.
106//
107// Spelled as explicit lowercase suffix/segment tests rather than a regex list,
108// so the predicate is exact on any path, including ones no fixture covered.
109const INFRA_BASENAMES: [&str; 2] = ["program.cs", "startup.cs"];
110const INFRA_SUFFIXES: [&str; 8] = [
111    "serviceextensions.cs",
112    "servicecollectionextensions.cs",
113    "registration.cs",
114    "testbase.cs",
115    "testsbase.cs",
116    "basetest.cs",
117    "basetests.cs",
118    "basefixture.cs",
119];
120const INFRA_DIR: &str = "dependencyresolution/";
121const COMPOSITION_ROOT: &str = "compositionroot";
122
123/// A file whose PATH says it is infrastructure. Always on: unlike the
124/// in-degree threshold, this is a classification of what the file is FOR, and
125/// `0` on the threshold does not turn a composition root back into an ordinary
126/// file.
127pub fn is_infra_file(file: &str) -> bool {
128    let lower = file.to_lowercase();
129    let base = match lower.rfind('/') {
130        Some(i) => &lower[i + 1..],
131        None => &lower[..],
132    };
133    if INFRA_BASENAMES.contains(&base) {
134        return true;
135    }
136    if INFRA_SUFFIXES.iter().any(|sfx| lower.ends_with(sfx)) {
137        return true;
138    }
139    if lower.starts_with(INFRA_DIR) || lower.contains(&format!("/{INFRA_DIR}")) {
140        return true;
141    }
142    // `CompositionRoot<Anything alphanumeric>.cs`, anywhere in the path's last
143    // segment -- the composition root itself and the test class that asserts it.
144    if let Some(at) = base.find(COMPOSITION_ROOT) {
145        if base.ends_with(".cs") {
146            let middle = &base[at + COMPOSITION_ROOT.len()..base.len() - 3];
147            if middle.chars().all(|c| c.is_ascii_alphanumeric()) {
148                return true;
149            }
150        }
151    }
152    false
153}
154/// Default damping factor for the personalized PageRank pass.
155pub const DEFAULT_DAMPING: f64 = 0.85;
156/// Default iteration count for the personalized PageRank pass.
157pub const DEFAULT_ITERATIONS: u32 = 20;
158
159// ============================================================================
160// The full name index, and the source line a hit points at.
161// ============================================================================
162
163/// The literal line a declaration sits on, ASCII-trimmed by `trim_source`.
164/// Returns `""` on an unreadable file or an out-of-range line; the caller then
165/// prints `file:line` with nothing after it rather than a row ending in a
166/// dangling separator.
167pub fn source_line(root: &Path, file: &str, line: usize) -> String {
168    let Ok(body) = std::fs::read_to_string(root.join(file)) else {
169        return String::new();
170    };
171    let lines: Vec<&str> = body.split('\n').collect();
172    if line < 1 || line > lines.len() {
173        return String::new();
174    }
175    let mut raw = lines[line - 1];
176    // A UTF-8 BOM survives the read and is not in the trim set, so line 1 of a
177    // BOM'd file would otherwise print it.
178    if line == 1 {
179        raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
180    }
181    trim_source(raw).to_string()
182}
183
184/// Name search: every whitespace-split token of `query` has to appear,
185/// case-insensitively, as a substring of the declared NAME -- path and purpose
186/// are the manifest search's haystack, not this one's. The caller owns the
187/// graph (see the module header's two-phase note). Results come back in index
188/// build order; ranking is the caller's job.
189pub fn find_names<'g>(graph: &'g graph::Graph, query: &str) -> Vec<&'g graph::GraphName> {
190    let tokens: Vec<String> = query
191        .to_lowercase()
192        .split_whitespace()
193        .map(String::from)
194        .collect();
195    if tokens.is_empty() {
196        return Vec::new();
197    }
198    graph
199        .names
200        .iter()
201        .filter(|n| {
202            let hay = n.name.to_lowercase();
203            tokens.iter().all(|t| hay.contains(t.as_str()))
204        })
205        .collect()
206}
207
208/// Every FILE's own first declaration line, keyed by path: the minimum `line`
209/// across every `graph.names` entry that file carries, over the WHOLE index.
210/// Unlike [`find_names`], this is never filtered to one query's matches,
211/// because a manifest-pool row needs a line to open regardless of the query. A
212/// file the name index carries nothing for (no declared symbol at all -- a
213/// config file, a doc, an asset) has no entry here; the caller falls back to
214/// line 1.
215pub fn first_decl_line_by_file(graph: &graph::Graph) -> HashMap<String, usize> {
216    let mut lines: HashMap<String, usize> = HashMap::new();
217    for n in &graph.names {
218        lines
219            .entry(n.file.clone())
220            .and_modify(|line| *line = (*line).min(n.line))
221            .or_insert(n.line);
222    }
223    lines
224}
225
226/// The per-file precise inbound-edge count `find`'s tie-break ranks by.
227///
228/// File path -> how many PRECISE reference edges land on the definitions that
229/// file declares, keyed by the edge's target file. This direct edge count IS
230/// the whole centrality measure behind `find`'s tie-break -- it deliberately
231/// shares nothing with `impact_walk`'s weighting (no hops, no `PageRank`, no
232/// distinct-referrer folding): one precise edge, one count.
233///
234/// Counted: `inherits`/`uses-type`/`uses-member` without the guess tag, plus
235/// the three TS reference kinds `call`/`jsx-use`/`dispatch`, which on a TS repo
236/// ARE the reference graph -- leaving them out would rank every TS file at
237/// zero. NOT counted: heuristic edges (a guess never enters a table a fact is
238/// read from), `imports`/`import` (they name a namespace or module, never a
239/// definition), `ctor-di` (DI wiring, not a reference), `ambiguous` (candidates,
240/// not resolutions), and a file's references to ITSELF (`from_file ==
241/// to_file`) -- how much OTHER code pulls on a file is the measure, so a
242/// self-citation is no more centrality here than it is for `hub_indegree`.
243pub fn file_inbound_counts(graph: &graph::Graph) -> HashMap<String, usize> {
244    let mut counts: HashMap<String, usize> = HashMap::new();
245    for e in &graph.edges {
246        let (from_file, to_file) = match e {
247            graph::Edge::Inherits {
248                from_file,
249                to_file,
250                heuristic,
251                ..
252            }
253            | graph::Edge::UsesType {
254                from_file,
255                to_file,
256                heuristic,
257                ..
258            }
259            | graph::Edge::UsesMember {
260                from_file,
261                to_file,
262                heuristic,
263                ..
264            } => {
265                if *heuristic {
266                    continue;
267                }
268                (from_file, to_file)
269            }
270            graph::Edge::Call {
271                from_file, to_file, ..
272            }
273            | graph::Edge::JsxUse {
274                from_file, to_file, ..
275            }
276            | graph::Edge::Dispatch {
277                from_file, to_file, ..
278            } => (from_file, to_file),
279            // Listed rather than caught by a wildcard so a future edge kind
280            // still fails the exhaustiveness check here.
281            graph::Edge::Imports { .. }
282            | graph::Edge::Import { .. }
283            | graph::Edge::CtorDi { .. }
284            | graph::Edge::Ambiguous { .. } => continue,
285        };
286        if from_file == to_file {
287            continue;
288        }
289        *counts.entry(to_file.clone()).or_insert(0) += 1;
290    }
291    counts
292}
293
294/// Collapses `suggest::kind_rank` into three tiers for `find`'s default
295/// view: tier 1 is a code symbol (`kind_rank` 0 -- a type or top-level
296/// declaration -- or 1 -- a member), tier 2 is a markup or binding name
297/// (`kind_rank` 2), tier 3 is a resource key (`kind_rank` 3). `find` matches
298/// tier 1 then 2 by default and demotes tier 3 to a one-line trailer
299/// (`--resources` includes it).
300pub fn name_tier(kind: &str) -> u8 {
301    let rank = kind_rank(kind);
302    if rank <= 1 {
303        1
304    } else {
305        rank
306    }
307}
308
309// ============================================================================
310// Order-preserving scratch structures (private -- see module header).
311// ============================================================================
312
313/// Insertion-order-preserving set: `insert` is a no-op when the value is
314/// already present (first insertion wins the position). Public because it
315/// appears in [`ImpactWalkResult`]'s public fields.
316#[derive(Debug, Clone)]
317pub struct SeqSet<T: Eq + std::hash::Hash + Clone> {
318    order: Vec<T>,
319    seen: HashSet<T>,
320}
321
322impl<T: Eq + std::hash::Hash + Clone> SeqSet<T> {
323    /// Creates an empty insertion-ordered set.
324    pub fn new() -> Self {
325        Self {
326            order: Vec::new(),
327            seen: HashSet::new(),
328        }
329    }
330    /// Inserts `value` if it is not already present.
331    pub fn insert(&mut self, value: T) {
332        if self.seen.insert(value.clone()) {
333            self.order.push(value);
334        }
335    }
336    /// Returns whether `value` is present.
337    pub fn contains(&self, value: &T) -> bool {
338        self.seen.contains(value)
339    }
340    /// Returns the number of values.
341    pub fn len(&self) -> usize {
342        self.order.len()
343    }
344    /// Iterates over values in insertion order.
345    pub fn iter(&self) -> std::slice::Iter<'_, T> {
346        self.order.iter()
347    }
348    /// Consumes the set and returns its values in insertion order.
349    pub fn into_vec(self) -> Vec<T> {
350        self.order
351    }
352}
353
354impl<T: Eq + std::hash::Hash + Clone> Default for SeqSet<T> {
355    fn default() -> Self {
356        SeqSet::new()
357    }
358}
359
360/// Insertion-order-preserving `String`-keyed map with a mutable
361/// get-or-insert-default accessor. Same shape as `graph::OrderedMap`, kept as
362/// a separate small type here because that one exposes no `get_mut`/entry-style
363/// API. Public for the same reason as [`SeqSet`] -- appears in
364/// [`ImpactWalkResult::visited`].
365#[derive(Debug, Clone)]
366pub struct SeqMap<V> {
367    entries: Vec<(String, V)>,
368    index: HashMap<String, usize>,
369}
370
371impl<V> SeqMap<V> {
372    /// Creates an empty insertion-ordered map.
373    pub fn new() -> Self {
374        Self {
375            entries: Vec::new(),
376            index: HashMap::new(),
377        }
378    }
379    /// Returns whether `key` is present.
380    pub fn contains_key(&self, key: &str) -> bool {
381        self.index.contains_key(key)
382    }
383    /// Inserts or replaces the value for `key` while preserving key order.
384    pub fn insert(&mut self, key: String, value: V) {
385        match self.index.get(&key) {
386            Some(&i) => self.entries[i].1 = value,
387            None => {
388                self.index.insert(key.clone(), self.entries.len());
389                self.entries.push((key, value));
390            }
391        }
392    }
393    /// Iterates over entries in insertion order.
394    pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
395        self.entries.iter().map(|(k, v)| (k, v))
396    }
397    /// Iterates over keys in insertion order.
398    pub fn keys(&self) -> impl Iterator<Item = &String> {
399        self.entries.iter().map(|(k, _)| k)
400    }
401}
402
403impl<V: Default> SeqMap<V> {
404    fn get_or_insert_default(&mut self, key: &str) -> &mut V {
405        if let Some(&i) = self.index.get(key) {
406            return &mut self.entries[i].1;
407        }
408        self.index.insert(key.to_string(), self.entries.len());
409        self.entries.push((key.to_string(), V::default()));
410        let last = self.entries.len() - 1;
411        &mut self.entries[last].1
412    }
413}
414
415fn push_ordered_unique(map: &mut HashMap<String, Vec<usize>>, key: &str, value: usize) {
416    let v = map.entry(key.to_string()).or_default();
417    if !v.contains(&value) {
418        v.push(value);
419    }
420}
421
422// ============================================================================
423// GraphIndex -- the built query index.
424// ============================================================================
425
426#[derive(Debug, Clone, Default)]
427/// Represents `InboundEntry`.
428pub struct InboundEntry {
429    /// The inherits value.
430    pub inherits: Vec<usize>,
431    /// The uses type value.
432    pub uses_type: Vec<usize>,
433    /// The uses member value.
434    pub uses_member: Vec<usize>,
435}
436
437/// The value shape of both `heuristic_inbound` and `heuristic_outbound_by_file`
438/// (`inherits`/`uses-type`/`uses-member` -- note no `imports`: an imports edge
439/// names a namespace string, never a def, so no tier can guess one).
440/// Structurally identical to [`InboundEntry`], and deliberately the same type.
441pub type HeuristicEntry = InboundEntry;
442
443#[derive(Debug, Clone, Default)]
444/// Represents `OutboundEntry`.
445pub struct OutboundEntry {
446    /// The inherits value.
447    pub inherits: Vec<usize>,
448    /// The uses type value.
449    pub uses_type: Vec<usize>,
450    /// The uses member value.
451    pub uses_member: Vec<usize>,
452    /// The imports value.
453    pub imports: Vec<usize>,
454}
455
456/// The built query index over a `Graph`. `by_id`/`by_file`/`by_simple_name`/
457/// `by_lower_name` store `graph.defs` indices rather than cloned `Def`s, so a
458/// def is never copied into every bucket it is reachable from.
459pub struct GraphIndex<'g> {
460    /// The graph value.
461    pub graph: &'g graph::Graph,
462    /// The repository root every `file` in the graph is relative to. Held so a
463    /// query can read the one source line a hit sits on; nothing else in this
464    /// module touches the filesystem.
465    pub root: std::path::PathBuf,
466    /// Def id -> index into `graph.defs`.
467    pub by_id: HashMap<String, usize>,
468    /// File -> the def indices declared in it. The `Vec` preserves insertion
469    /// order (defs-array order for a def's own file, then `also_in`
470    /// cross-references in defs-array order) -- `impact_walk`'s next-frontier
471    /// expansion iterates this in order, one of the order-sensitive paths
472    /// documented in the module header.
473    pub by_file: HashMap<String, Vec<usize>>,
474    /// Exact name -> def indices, insertion order = `graph.defs` array order.
475    pub by_simple_name: HashMap<String, Vec<usize>>,
476    /// Lowercased name -> def indices.
477    pub by_lower_name: HashMap<String, Vec<usize>>,
478    /// Def id -> its inbound edges by kind (inherits/uses-type/uses-member).
479    pub inbound: HashMap<String, InboundEntry>,
480    /// File -> its outbound edges by kind (inherits/uses-type/uses-member/imports).
481    pub outbound_by_file: HashMap<String, OutboundEntry>,
482    /// Candidate def id -> the ambiguous edges naming it as a candidate.
483    pub ambiguous_by_candidate: HashMap<String, Vec<usize>>,
484    /// Source file -> the ambiguous edges originating in it.
485    pub ambiguous_by_file: HashMap<String, Vec<usize>>,
486    /// Def id -> its inbound HEURISTIC edges by kind. Heuristic edges live in
487    /// their own adjacency, never mixed into the precise one, so every consumer
488    /// (the impact walk's frontier, the refs tables, PageRank's edge set) reads
489    /// only precise edges unless it asks for guesses by name. Mixing them in
490    /// and filtering later is the shape that eventually leaks a guess into a
491    /// fact -- a filter forgotten in one call site is silent.
492    pub heuristic_inbound: HashMap<String, HeuristicEntry>,
493    /// File -> its outbound HEURISTIC edges (same three-kind shape).
494    pub heuristic_outbound_by_file: HashMap<String, HeuristicEntry>,
495    /// File -> the defs declared in it that carry non-empty `test_methods`. A
496    /// file is a TEST file here because a type declared in it carries an
497    /// attribute a runner discovers, never because of what it is called. Every
498    /// declaring site counts (`def.file` plus each `also_in`), and the per-def
499    /// file dedup keeps two partial blocks in ONE file from listing that def
500    /// twice. Holds `graph.defs` indices, same convention as the buckets above.
501    pub test_defs_by_file: HashMap<String, Vec<usize>>,
502    /// Files present in the graph but absent from the manifest.
503    pub flagged_files: HashSet<String>,
504    /// Whether a manifest was found and parsed.
505    pub manifest_present: bool,
506    /// Implementor def id -> every `ctor-di` edge the resolver confirmed
507    /// resolves TO it. Only an edge carrying a `to` is indexed here --
508    /// ambiguous/infra/unresolved edges never do, so an infra leaf like
509    /// `ILogger<T>` can never enter this map by construction.
510    pub ctor_di_by_to: HashMap<String, Vec<usize>>,
511    /// Injected-type BARE NAME -> the number of DISTINCT CONSTRUCTOR SITES
512    /// (`from_file` + `from_line`) that inject it. A `ctor-param` ref carries
513    /// its constructor's line, so every parameter of one constructor shares one
514    /// site; two classes in one file still count twice. This is the graph's
515    /// proxy for "distinct consuming classes". Every ctor-di edge counts,
516    /// whatever its resolution: how widely a contract is injected is a fact
517    /// about the contract, not about whether an implementor was confirmed.
518    pub ctor_di_fanin: HashMap<String, usize>,
519    /// File -> the number of DISTINCT OTHER FILES that reference it through a
520    /// `direct` (inherits/uses-type/uses-member) or heuristic edge. The
521    /// file-level mirror of `ctor_di_fanin`, over the two edge kinds
522    /// responsible for the reach a fan-in brake cannot see. Distinct REFERRING
523    /// FILES, not distinct edges: one neighbour naming a hub fifty times is
524    /// still one neighbour. A file's references to ITSELF are excluded -- a
525    /// file is never its own dependant.
526    pub hub_indegree: HashMap<String, usize>,
527}
528
529impl<'g> GraphIndex<'g> {
530    /// Look up a def by id, e.g. for rendering an ambiguous candidate's
531    /// `{file, line, kind}`. Spelled as a method so callers don't reach through
532    /// `by_id` plus index arithmetic themselves.
533    pub fn def(&self, id: &str) -> Option<&graph::Def> {
534        self.by_id.get(id).map(|&i| &self.graph.defs[i])
535    }
536}
537
538fn note_file(flagged: &mut HashSet<String>, manifest_paths: Option<&HashSet<String>>, file: &str) {
539    if let Some(paths) = manifest_paths {
540        if !file.is_empty() && !paths.contains(file) {
541            flagged.insert(file.to_string());
542        }
543    }
544}
545
546/// Build the query index, phase two of the two-phase build (see module
547/// header). `graph` is what the caller got back from `graph::read_graph(root)`;
548/// `root` is used here only for the manifest join.
549pub fn load_graph_index<'g>(graph: &'g graph::Graph, root: &Path) -> GraphIndex<'g> {
550    let manifest_value = match manifest::read_manifest(root) {
551        Ok(v) => v,
552        Err(_) => None, // corrupt manifest.json: fail open, see module header
553    };
554    let manifest_present = manifest_value.is_some();
555    let manifest_paths: Option<HashSet<String>> = manifest_value.as_ref().and_then(|m| {
556        m.get("entries")
557            .and_then(|e| e.as_object())
558            .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
559    });
560    let manifest_paths_ref = manifest_paths.as_ref();
561
562    let mut flagged_files: HashSet<String> = HashSet::new();
563    let mut by_id: HashMap<String, usize> = HashMap::with_capacity(graph.defs.len());
564    let mut by_file: HashMap<String, Vec<usize>> = HashMap::new();
565    let mut by_simple_name: HashMap<String, Vec<usize>> = HashMap::new();
566    let mut by_lower_name: HashMap<String, Vec<usize>> = HashMap::new();
567    let mut test_defs_by_file: HashMap<String, Vec<usize>> = HashMap::new();
568
569    for (i, d) in graph.defs.iter().enumerate() {
570        by_id.insert(d.id.clone(), i);
571        note_file(&mut flagged_files, manifest_paths_ref, &d.file);
572        push_ordered_unique(&mut by_file, &d.file, i);
573        for also in &d.also_in {
574            note_file(&mut flagged_files, manifest_paths_ref, &also.file);
575            push_ordered_unique(&mut by_file, &also.file, i);
576        }
577        if !d.test_methods.is_empty() {
578            let mut files: Vec<&str> = vec![d.file.as_str()];
579            for also in &d.also_in {
580                if !files.contains(&also.file.as_str()) {
581                    files.push(also.file.as_str());
582                }
583            }
584            for file in files {
585                test_defs_by_file
586                    .entry(file.to_string())
587                    .or_default()
588                    .push(i);
589            }
590        }
591        by_simple_name.entry(d.name.clone()).or_default().push(i);
592        by_lower_name
593            .entry(d.name.to_lowercase())
594            .or_default()
595            .push(i);
596    }
597
598    let mut inbound: HashMap<String, InboundEntry> = HashMap::new();
599    let mut outbound_by_file: HashMap<String, OutboundEntry> = HashMap::new();
600    let mut ambiguous_by_candidate: HashMap<String, Vec<usize>> = HashMap::new();
601    let mut ambiguous_by_file: HashMap<String, Vec<usize>> = HashMap::new();
602    let mut heuristic_inbound: HashMap<String, HeuristicEntry> = HashMap::new();
603    let mut heuristic_outbound_by_file: HashMap<String, HeuristicEntry> = HashMap::new();
604    let mut ctor_di_by_to: HashMap<String, Vec<usize>> = HashMap::new();
605    let mut ctor_di_sites_by_iface: HashMap<String, HashSet<(String, usize)>> = HashMap::new();
606    // File -> the distinct other files that reference it (see `hub_indegree`).
607    let mut hub_referrers_by_file: HashMap<String, HashSet<String>> = HashMap::new();
608
609    for (i, e) in graph.edges.iter().enumerate() {
610        match e {
611            graph::Edge::Imports { from_file, .. } => {
612                note_file(&mut flagged_files, manifest_paths_ref, from_file);
613                outbound_by_file
614                    .entry(from_file.clone())
615                    .or_default()
616                    .imports
617                    .push(i);
618            }
619            graph::Edge::Ambiguous {
620                from_file,
621                candidates,
622                ..
623            } => {
624                note_file(&mut flagged_files, manifest_paths_ref, from_file);
625                ambiguous_by_file
626                    .entry(from_file.clone())
627                    .or_default()
628                    .push(i);
629                for c in candidates {
630                    ambiguous_by_candidate
631                        .entry(c.id.clone())
632                        .or_default()
633                        .push(i);
634                }
635            }
636            graph::Edge::Inherits {
637                from_file,
638                to,
639                to_file,
640                heuristic,
641                ..
642            } => {
643                note_file(&mut flagged_files, manifest_paths_ref, from_file);
644                note_file(&mut flagged_files, manifest_paths_ref, to_file);
645                // Counted BEFORE the heuristic split below: the hub brake spans
646                // both kinds on purpose, because a hub file is reached through
647                // whichever of them the extractor happened to resolve.
648                if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
649                    hub_referrers_by_file
650                        .entry(to_file.clone())
651                        .or_default()
652                        .insert(from_file.clone());
653                }
654                if *heuristic {
655                    heuristic_outbound_by_file
656                        .entry(from_file.clone())
657                        .or_default()
658                        .inherits
659                        .push(i);
660                    heuristic_inbound
661                        .entry(to.clone())
662                        .or_default()
663                        .inherits
664                        .push(i);
665                } else {
666                    outbound_by_file
667                        .entry(from_file.clone())
668                        .or_default()
669                        .inherits
670                        .push(i);
671                    inbound.entry(to.clone()).or_default().inherits.push(i);
672                }
673            }
674            graph::Edge::UsesType {
675                from_file,
676                to,
677                to_file,
678                heuristic,
679                ..
680            } => {
681                note_file(&mut flagged_files, manifest_paths_ref, from_file);
682                note_file(&mut flagged_files, manifest_paths_ref, to_file);
683                // Counted BEFORE the heuristic split below: the hub brake spans
684                // both kinds on purpose, because a hub file is reached through
685                // whichever of them the extractor happened to resolve.
686                if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
687                    hub_referrers_by_file
688                        .entry(to_file.clone())
689                        .or_default()
690                        .insert(from_file.clone());
691                }
692                if *heuristic {
693                    heuristic_outbound_by_file
694                        .entry(from_file.clone())
695                        .or_default()
696                        .uses_type
697                        .push(i);
698                    heuristic_inbound
699                        .entry(to.clone())
700                        .or_default()
701                        .uses_type
702                        .push(i);
703                } else {
704                    outbound_by_file
705                        .entry(from_file.clone())
706                        .or_default()
707                        .uses_type
708                        .push(i);
709                    inbound.entry(to.clone()).or_default().uses_type.push(i);
710                }
711            }
712            graph::Edge::UsesMember {
713                from_file,
714                to,
715                to_file,
716                heuristic,
717                ..
718            } => {
719                note_file(&mut flagged_files, manifest_paths_ref, from_file);
720                note_file(&mut flagged_files, manifest_paths_ref, to_file);
721                // Counted BEFORE the heuristic split below: the hub brake spans
722                // both kinds on purpose, because a hub file is reached through
723                // whichever of them the extractor happened to resolve.
724                if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
725                    hub_referrers_by_file
726                        .entry(to_file.clone())
727                        .or_default()
728                        .insert(from_file.clone());
729                }
730                if *heuristic {
731                    heuristic_outbound_by_file
732                        .entry(from_file.clone())
733                        .or_default()
734                        .uses_member
735                        .push(i);
736                    heuristic_inbound
737                        .entry(to.clone())
738                        .or_default()
739                        .uses_member
740                        .push(i);
741                } else {
742                    outbound_by_file
743                        .entry(from_file.clone())
744                        .or_default()
745                        .uses_member
746                        .push(i);
747                    inbound.entry(to.clone()).or_default().uses_member.push(i);
748                }
749            }
750            // 'ctor-di' is deliberately NOT one of the kinds `refs`/`impact`
751            // render -- that kind list is fixed to
752            // inherits/uses-type/uses-member -- so it earns no `inbound`/
753            // `outbound_by_file` entry. It DOES earn its own reverse index,
754            // keyed by the resolved implementor, when it carries one.
755            graph::Edge::CtorDi {
756                from_file,
757                from_line,
758                iface,
759                to,
760                ..
761            } => {
762                // Fan-in counts EVERY ctor-di edge, resolved or not; only the
763                // reverse index below is restricted to the ones carrying a
764                // confirmed implementor.
765                ctor_di_sites_by_iface
766                    .entry(iface.clone())
767                    .or_default()
768                    .insert((from_file.clone(), *from_line));
769                if let Some(to) = to {
770                    ctor_di_by_to.entry(to.clone()).or_default().push(i);
771                }
772            }
773            // The four TS/TSX edge kinds earn no entry in this C#-shaped index,
774            // the same stance the `ctor-di` arm above states for its own kind:
775            // `refs`/`impact` render a fixed kind list. Listed rather than
776            // caught by a wildcard so a future edge kind still fails the
777            // exhaustiveness check here.
778            graph::Edge::Import { .. }
779            | graph::Edge::Call { .. }
780            | graph::Edge::JsxUse { .. }
781            | graph::Edge::Dispatch { .. } => {}
782        }
783    }
784
785    GraphIndex {
786        graph,
787        root: root.to_path_buf(),
788        by_id,
789        by_file,
790        by_simple_name,
791        by_lower_name,
792        inbound,
793        outbound_by_file,
794        ambiguous_by_candidate,
795        ambiguous_by_file,
796        heuristic_inbound,
797        heuristic_outbound_by_file,
798        test_defs_by_file,
799        flagged_files,
800        manifest_present,
801        ctor_di_by_to,
802        ctor_di_fanin: ctor_di_sites_by_iface
803            .into_iter()
804            .map(|(name, sites)| (name, sites.len()))
805            .collect(),
806        hub_indegree: hub_referrers_by_file
807            .into_iter()
808            .map(|(file, refs)| (file, refs.len()))
809            .collect(),
810    }
811}
812
813// The interface def id(s) a class def's OWN file(s) declare an `inherits` edge
814// to, restricted to a def of kind `"interface"` -- a plain base class is not
815// part of the interface hop. Reuses `outbound_by_file`'s file-level union
816// rather than attributing an edge to one specific def in a multi-type file (an
817// accepted imprecision). Insertion order is `def_files(def_id)` order, then
818// that file's own inherits-edge array order -- the first-seen order of the
819// `via` labels a widened hit carries.
820fn implemented_interfaces(index: &GraphIndex, def_id: &str) -> Vec<String> {
821    let mut seen: SeqSet<String> = SeqSet::new();
822    for file in def_files(index, def_id) {
823        let Some(o) = index.outbound_by_file.get(&file) else {
824            continue;
825        };
826        for &ei in &o.inherits {
827            let graph::Edge::Inherits { to, .. } = &index.graph.edges[ei] else {
828                continue;
829            };
830            if seen.contains(to) {
831                continue;
832            }
833            if index.def(to).map(|d| d.kind.as_str()) != Some("interface") {
834                continue;
835            }
836            seen.insert(to.clone());
837        }
838    }
839    seen.into_vec()
840}
841
842// ============================================================================
843// resolve_symbol.
844// ============================================================================
845
846#[derive(Debug, Clone, PartialEq, Eq)]
847/// Represents `Resolution`.
848pub enum Resolution {
849    /// Represents `Resolved`.
850    Resolved(String),
851    /// Represents `Ambiguous`.
852    Ambiguous(Vec<String>),
853    /// Represents `NotFound`.
854    NotFound,
855}
856
857/// "Never guess" resolution ladder: exact id, then unique exact name, then
858/// unique case-insensitive name; two-or-more candidates at any step is reported
859/// as ambiguous, not resolved further.
860pub fn resolve_symbol(index: &GraphIndex, query: &str) -> Resolution {
861    if index.by_id.contains_key(query) {
862        return Resolution::Resolved(query.to_string());
863    }
864    if let Some(exact) = index.by_simple_name.get(query) {
865        if exact.len() == 1 {
866            return Resolution::Resolved(index.graph.defs[exact[0]].id.clone());
867        }
868        if exact.len() > 1 {
869            return Resolution::Ambiguous(
870                exact
871                    .iter()
872                    .map(|&i| index.graph.defs[i].id.clone())
873                    .collect(),
874            );
875        }
876    }
877    let lower = query.to_lowercase();
878    if let Some(ci) = index.by_lower_name.get(&lower) {
879        if ci.len() == 1 {
880            return Resolution::Resolved(index.graph.defs[ci[0]].id.clone());
881        }
882        if ci.len() > 1 {
883            return Resolution::Ambiguous(
884                ci.iter().map(|&i| index.graph.defs[i].id.clone()).collect(),
885            );
886        }
887    }
888    // A TAIL of a def id, which is how a caller spells an enum member:
889    // `Toggles.EnableX`, never the namespace-qualified
890    // `App.Config.Toggles.EnableX` the graph keys it under. Reached only after
891    // every step above has missed, so no query that resolved before still
892    // resolves differently. Restricted to a dotted query on purpose -- every
893    // def is indexed under its simple name, so `by_simple_name` above is
894    // already exhaustive for an undotted one and this step could only repeat
895    // it. Two or more ids ending in the same tail is an ambiguity, reported as
896    // one rather than guessed at. Iterates `graph.defs` directly (its array
897    // order) rather than the unordered `by_id` map.
898    if query.contains('.') {
899        let suffix = format!(".{query}");
900        let tail: Vec<String> = index
901            .graph
902            .defs
903            .iter()
904            .filter(|d| d.id.ends_with(&suffix))
905            .map(|d| d.id.clone())
906            .collect();
907        if tail.len() == 1 {
908            return Resolution::Resolved(tail.into_iter().next().expect("one tail match"));
909        }
910        if tail.len() > 1 {
911            return Resolution::Ambiguous(tail);
912        }
913    }
914    Resolution::NotFound
915}
916
917// ============================================================================
918// def_files / def_sites.
919// ============================================================================
920
921#[derive(Debug, Clone, PartialEq)]
922/// Represents `DefSite`.
923pub struct DefSite {
924    /// The file value.
925    pub file: String,
926    /// The line value.
927    pub line: usize,
928}
929
930/// The distinct files a def is declared in, deduped: a partial type's
931/// `also_in` site can share a file with its primary declaration.
932pub fn def_files(index: &GraphIndex, def_id: &str) -> Vec<String> {
933    let mut out = Vec::new();
934    let mut seen = HashSet::new();
935    if let Some(&i) = index.by_id.get(def_id) {
936        let d = &index.graph.defs[i];
937        if seen.insert(d.file.clone()) {
938            out.push(d.file.clone());
939        }
940        for also in &d.also_in {
941            if seen.insert(also.file.clone()) {
942                out.push(also.file.clone());
943            }
944        }
945    }
946    out
947}
948
949/// Every declaring site of a def, NOT deduped (unlike [`def_files`]) -- each
950/// site is its own row.
951pub fn def_sites(index: &GraphIndex, def_id: &str) -> Vec<DefSite> {
952    let mut out = Vec::new();
953    if let Some(&i) = index.by_id.get(def_id) {
954        let d = &index.graph.defs[i];
955        out.push(DefSite {
956            file: d.file.clone(),
957            line: d.line,
958        });
959        for also in &d.also_in {
960            out.push(DefSite {
961                file: also.file.clone(),
962                line: also.line,
963            });
964        }
965    }
966    out
967}
968
969// ============================================================================
970// symbol_refs.
971// ============================================================================
972
973/// The immediate inbound/outbound edges of one resolved symbol, holding
974/// `graph.edges` indices rather than cloned edges (the same convention
975/// [`GraphIndex`] uses).
976#[derive(Debug, Clone, Default)]
977pub struct SymbolRefs {
978    /// The inbound inherits value.
979    pub inbound_inherits: Vec<usize>,
980    /// The inbound uses type value.
981    pub inbound_uses_type: Vec<usize>,
982    /// The inbound uses member value.
983    pub inbound_uses_member: Vec<usize>,
984    /// The outbound inherits value.
985    pub outbound_inherits: Vec<usize>,
986    /// The outbound uses type value.
987    pub outbound_uses_type: Vec<usize>,
988    /// The outbound uses member value.
989    pub outbound_uses_member: Vec<usize>,
990    /// The outbound imports value.
991    pub outbound_imports: Vec<usize>,
992    /// The same two tables again over the HEURISTIC adjacency, built by the
993    /// identical rules (enum members union into the enum's inbound, partial
994    /// classes union outbound over every declaring file) so a heuristic row is
995    /// never present or absent for a reason a precise row would not have been.
996    pub heuristic_inbound_inherits: Vec<usize>,
997    /// The heuristic inbound uses type value.
998    pub heuristic_inbound_uses_type: Vec<usize>,
999    /// The heuristic inbound uses member value.
1000    pub heuristic_inbound_uses_member: Vec<usize>,
1001    /// The heuristic outbound inherits value.
1002    pub heuristic_outbound_inherits: Vec<usize>,
1003    /// The heuristic outbound uses type value.
1004    pub heuristic_outbound_uses_type: Vec<usize>,
1005    /// The heuristic outbound uses member value.
1006    pub heuristic_outbound_uses_member: Vec<usize>,
1007    /// The ambiguous inbound value.
1008    pub ambiguous_inbound: Vec<usize>,
1009    /// The ambiguous outbound value.
1010    pub ambiguous_outbound: Vec<usize>,
1011}
1012
1013/// Immediate (1-hop) inbound/outbound for one resolved symbol. Partial-class
1014/// defs union outbound over every declaring file. An enum query unions every
1015/// member's inbound `uses-member` edges in, iterating `index.graph.defs`
1016/// directly (its array order) rather than the unordered `by_id` map.
1017pub fn symbol_refs(index: &GraphIndex, def_id: &str) -> SymbolRefs {
1018    let empty = InboundEntry::default();
1019    let base = index.inbound.get(def_id).unwrap_or(&empty);
1020    let inbound_inherits = base.inherits.clone();
1021    let inbound_uses_type = base.uses_type.clone();
1022    let mut inbound_uses_member = base.uses_member.clone();
1023
1024    if let Some(&i) = index.by_id.get(def_id) {
1025        if index.graph.defs[i].kind == "enum" {
1026            let prefix = format!("{def_id}.");
1027            for d in &index.graph.defs {
1028                if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
1029                    continue;
1030                }
1031                if let Some(m) = index.inbound.get(&d.id) {
1032                    inbound_uses_member.extend(m.uses_member.iter().copied());
1033                }
1034            }
1035        }
1036    }
1037
1038    let mut outbound_inherits = Vec::new();
1039    let mut outbound_uses_type = Vec::new();
1040    let mut outbound_uses_member = Vec::new();
1041    let mut outbound_imports = Vec::new();
1042    for file in def_files(index, def_id) {
1043        if let Some(o) = index.outbound_by_file.get(&file) {
1044            outbound_inherits.extend(o.inherits.iter().copied());
1045            outbound_uses_type.extend(o.uses_type.iter().copied());
1046            outbound_uses_member.extend(o.uses_member.iter().copied());
1047            outbound_imports.extend(o.imports.iter().copied());
1048        }
1049    }
1050
1051    // The heuristic halves, same rules, separate adjacency.
1052    let heuristic_base = index.heuristic_inbound.get(def_id).unwrap_or(&empty);
1053    let heuristic_inbound_inherits = heuristic_base.inherits.clone();
1054    let heuristic_inbound_uses_type = heuristic_base.uses_type.clone();
1055    let mut heuristic_inbound_uses_member = heuristic_base.uses_member.clone();
1056
1057    if let Some(&i) = index.by_id.get(def_id) {
1058        if index.graph.defs[i].kind == "enum" {
1059            let prefix = format!("{def_id}.");
1060            for d in &index.graph.defs {
1061                if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
1062                    continue;
1063                }
1064                if let Some(m) = index.heuristic_inbound.get(&d.id) {
1065                    heuristic_inbound_uses_member.extend(m.uses_member.iter().copied());
1066                }
1067            }
1068        }
1069    }
1070
1071    let mut heuristic_outbound_inherits = Vec::new();
1072    let mut heuristic_outbound_uses_type = Vec::new();
1073    let mut heuristic_outbound_uses_member = Vec::new();
1074    for file in def_files(index, def_id) {
1075        if let Some(o) = index.heuristic_outbound_by_file.get(&file) {
1076            heuristic_outbound_inherits.extend(o.inherits.iter().copied());
1077            heuristic_outbound_uses_type.extend(o.uses_type.iter().copied());
1078            heuristic_outbound_uses_member.extend(o.uses_member.iter().copied());
1079        }
1080    }
1081
1082    let ambiguous_inbound = index
1083        .ambiguous_by_candidate
1084        .get(def_id)
1085        .cloned()
1086        .unwrap_or_default();
1087    let ambiguous_outbound: Vec<usize> = def_files(index, def_id)
1088        .iter()
1089        .flat_map(|f| index.ambiguous_by_file.get(f).cloned().unwrap_or_default())
1090        .collect();
1091
1092    SymbolRefs {
1093        inbound_inherits,
1094        inbound_uses_type,
1095        inbound_uses_member,
1096        outbound_inherits,
1097        outbound_uses_type,
1098        outbound_uses_member,
1099        outbound_imports,
1100        heuristic_inbound_inherits,
1101        heuristic_inbound_uses_type,
1102        heuristic_inbound_uses_member,
1103        heuristic_outbound_inherits,
1104        heuristic_outbound_uses_type,
1105        heuristic_outbound_uses_member,
1106        ambiguous_inbound,
1107        ambiguous_outbound,
1108    }
1109}
1110
1111// ============================================================================
1112// build_refs_model.
1113// ============================================================================
1114
1115fn cap_rows<T>(mut rows: Vec<T>, cap: usize) -> (Vec<T>, usize) {
1116    if rows.len() <= cap {
1117        (rows, 0)
1118    } else {
1119        let dropped = rows.len() - cap;
1120        rows.truncate(cap);
1121        (rows, dropped)
1122    }
1123}
1124
1125// The (from_file, from_line) an edge originates at, the key location sorts use.
1126// See the module header re: `str::cmp` ordering.
1127fn edge_loc(e: &graph::Edge) -> (&str, usize) {
1128    match e {
1129        graph::Edge::Inherits { from_file, from_line, .. }
1130        | graph::Edge::UsesType { from_file, from_line, .. }
1131        | graph::Edge::UsesMember { from_file, from_line, .. }
1132        | graph::Edge::Imports { from_file, from_line, .. }
1133        | graph::Edge::Ambiguous { from_file, from_line, .. }
1134        // Never actually reached: 'ctor-di' edges are never pushed into any
1135        // structure this helper sorts (see the query-index builder's own
1136        // CtorDi arm). Included only for exhaustiveness, as are the four
1137        // TS/TSX kinds below, for the same reason.
1138        | graph::Edge::CtorDi { from_file, from_line, .. }
1139        | graph::Edge::Import { from_file, from_line, .. }
1140        | graph::Edge::Call { from_file, from_line, .. }
1141        | graph::Edge::JsxUse { from_file, from_line, .. }
1142        | graph::Edge::Dispatch { from_file, from_line, .. } => (from_file.as_str(), *from_line),
1143    }
1144}
1145
1146fn loc_cmp(a: &graph::Edge, b: &graph::Edge) -> std::cmp::Ordering {
1147    let (af, al) = edge_loc(a);
1148    let (bf, bl) = edge_loc(b);
1149    if af == bf {
1150        al.cmp(&bl)
1151    } else {
1152        af.cmp(bf)
1153    }
1154}
1155
1156/// One inbound-table row: `file` and `line` of the referencing site, then
1157/// `heuristic` (whether the edge was guessed) and `source` (the trimmed
1158/// referencing line). An empty `source` is omitted from `--json`.
1159#[derive(Debug, Clone, PartialEq)]
1160pub struct InboundRow {
1161    /// The file value.
1162    pub file: String,
1163    /// The line value.
1164    pub line: usize,
1165    /// The heuristic value.
1166    pub heuristic: bool,
1167    /// The source value.
1168    pub source: String,
1169}
1170
1171/// One outbound-table row: `file`/`line` of the referencing site, `to_file`/`to`
1172/// of the target, then `heuristic` and `source` (same omit-when-empty rule as
1173/// [`InboundRow`]). `source` is read at the site actually making the reference
1174/// -- for an outbound edge that is a line in the def's own file, not the
1175/// target's.
1176#[derive(Debug, Clone, PartialEq)]
1177pub struct OutboundRow {
1178    /// The file value.
1179    pub file: String,
1180    /// The line value.
1181    pub line: usize,
1182    /// The to file value.
1183    pub to_file: String,
1184    /// The to value.
1185    pub to: String,
1186    /// The heuristic value.
1187    pub heuristic: bool,
1188    /// The source value.
1189    pub source: String,
1190}
1191
1192/// One imports-table row: `file`/`line`, the imported `target` namespace, and
1193/// `source`. An imports edge is never a guess, so unlike [`OutboundRow`] this
1194/// carries no `heuristic` field.
1195#[derive(Debug, Clone, PartialEq)]
1196pub struct ImportRow {
1197    /// The file value.
1198    pub file: String,
1199    /// The line value.
1200    pub line: usize,
1201    /// The target value.
1202    pub target: String,
1203    /// The source value.
1204    pub source: String,
1205}
1206
1207/// One ambiguous-table row: the referencing `file`/`line`, the `origin` and
1208/// `raw` text of the reference, and how many candidates it matched.
1209#[derive(Debug, Clone, PartialEq)]
1210pub struct AmbiguousRow {
1211    /// The file value.
1212    pub file: String,
1213    /// The line value.
1214    pub line: usize,
1215    /// The origin value.
1216    pub origin: String,
1217    /// The raw value.
1218    pub raw: String,
1219    /// The candidate count value.
1220    pub candidate_count: usize,
1221}
1222
1223/// A capped table: `total` rows before capping, how many were `dropped`, and
1224/// the surviving `rows`.
1225#[derive(Debug, Clone, PartialEq)]
1226pub struct Table<R> {
1227    /// The total value.
1228    pub total: usize,
1229    /// The dropped value.
1230    pub dropped: usize,
1231    /// The rows value.
1232    pub rows: Vec<R>,
1233}
1234
1235fn build_table<R>(
1236    mut idxs: Vec<usize>,
1237    edges: &[graph::Edge],
1238    cap: usize,
1239    map_row: fn(&graph::Edge) -> R,
1240) -> Table<R> {
1241    idxs.sort_by(|&a, &b| loc_cmp(&edges[a], &edges[b]));
1242    let total = idxs.len();
1243    let (shown, dropped) = cap_rows(idxs, cap);
1244    let rows = shown.into_iter().map(|i| map_row(&edges[i])).collect();
1245    Table {
1246        total,
1247        dropped,
1248        rows,
1249    }
1250}
1251
1252// The per-table, per-kind outbound builder was removed here: the outbound
1253// tables now go through `build_outbound_tables`'s shared cap and rank instead
1254// (below `RankedOutbound`), the same way the inbound tables stopped going
1255// through a per-kind builder. `build_table` above still backs the two
1256// ambiguous tables.
1257
1258fn ambiguous_row(e: &graph::Edge) -> AmbiguousRow {
1259    match e {
1260        graph::Edge::Ambiguous {
1261            origin,
1262            from_file,
1263            from_line,
1264            raw,
1265            candidate_count,
1266            ..
1267        } => AmbiguousRow {
1268            file: from_file.clone(),
1269            line: *from_line,
1270            origin: origin.clone(),
1271            raw: raw.clone(),
1272            candidate_count: *candidate_count,
1273        },
1274        _ => unreachable!("ambiguous table only ever holds ambiguous edge indices"),
1275    }
1276}
1277
1278/// The three inbound tables, one per kind (inherits/uses-type/uses-member).
1279#[derive(Debug, Clone, PartialEq)]
1280pub struct InboundTables {
1281    /// The inherits value.
1282    pub inherits: Table<InboundRow>,
1283    /// The uses type value.
1284    pub uses_type: Table<InboundRow>,
1285    /// The uses member value.
1286    pub uses_member: Table<InboundRow>,
1287}
1288
1289/// The four outbound tables: three by kind plus imports.
1290#[derive(Debug, Clone, PartialEq)]
1291pub struct OutboundTables {
1292    /// The inherits value.
1293    pub inherits: Table<OutboundRow>,
1294    /// The uses type value.
1295    pub uses_type: Table<OutboundRow>,
1296    /// The uses member value.
1297    pub uses_member: Table<OutboundRow>,
1298    /// The imports value.
1299    pub imports: Table<ImportRow>,
1300}
1301
1302/// The inbound and outbound ambiguous-reference tables.
1303#[derive(Debug, Clone, PartialEq)]
1304pub struct AmbiguousTables {
1305    /// The inbound value.
1306    pub inbound: Table<AmbiguousRow>,
1307    /// The outbound value.
1308    pub outbound: Table<AmbiguousRow>,
1309}
1310
1311/// One enum member that carries at least one inbound reference.
1312#[derive(Debug, Clone, PartialEq)]
1313pub struct MemberRefEntry {
1314    /// The name value.
1315    pub name: String,
1316    /// The count value.
1317    pub count: usize,
1318}
1319
1320/// How many of an ENUM's inbound member edges land on one of its members,
1321/// split by member.
1322#[derive(Debug, Clone, PartialEq)]
1323pub struct MemberRefs {
1324    /// The total value.
1325    pub total: usize,
1326    /// The member count value.
1327    pub member_count: usize,
1328    /// The members value.
1329    pub members: Vec<MemberRefEntry>,
1330    /// The dropped value.
1331    pub dropped: usize,
1332}
1333
1334// Maximum number of per-member rows kept in a `MemberRefs`.
1335const MEMBER_NAME_CAP: usize = 5;
1336
1337// The edges themselves are already there (a `Toggles.EnableX` access resolves
1338// to the member def and `symbol_refs` unions every member's inbound into the
1339// enum's), but a caller reading `refs Toggles` could not tell a use of the
1340// TYPE from a use of a member, and the per-member split is the thing an enum
1341// question is usually actually about.
1342//
1343// Member order is def-table order (declaration order), never by count: a
1344// stable list is what makes two runs print the same line. Iterates
1345// `graph.defs` directly for the same reason `symbol_refs`'s own enum union
1346// does -- that array order is the order to preserve.
1347fn enum_member_refs(index: &GraphIndex, def_id: &str) -> Option<MemberRefs> {
1348    let prefix = format!("{def_id}.");
1349    let mut members: Vec<MemberRefEntry> = Vec::new();
1350    let mut total = 0usize;
1351    for d in &index.graph.defs {
1352        if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
1353            continue;
1354        }
1355        let count = index.inbound.get(&d.id).map_or(0, |e| e.uses_member.len())
1356            + index
1357                .heuristic_inbound
1358                .get(&d.id)
1359                .map_or(0, |e| e.uses_member.len());
1360        if count == 0 {
1361            continue;
1362        }
1363        members.push(MemberRefEntry {
1364            name: d.name.clone(),
1365            count,
1366        });
1367        total += count;
1368    }
1369    if total == 0 {
1370        return None;
1371    }
1372    let member_count = members.len();
1373    let dropped = member_count.saturating_sub(MEMBER_NAME_CAP);
1374    members.truncate(MEMBER_NAME_CAP);
1375    Some(MemberRefs {
1376        total,
1377        member_count,
1378        members,
1379        dropped,
1380    })
1381}
1382
1383/// The resolved `refs` result for one symbol.
1384#[derive(Debug, Clone, PartialEq)]
1385pub struct RefsModel {
1386    /// The query value.
1387    pub query: String,
1388    /// The id value.
1389    pub id: String,
1390    /// The kind value.
1391    pub kind: String,
1392    /// The sites value.
1393    pub sites: Vec<DefSite>,
1394    /// The inbound value.
1395    pub inbound: InboundTables,
1396    /// Present only under `--out`; `None` otherwise, which is what keeps
1397    /// `--json` and the two text renderers agreeing about whether the outbound
1398    /// side exists at all.
1399    pub outbound: Option<OutboundTables>,
1400    /// The ambiguous value.
1401    pub ambiguous: AmbiguousTables,
1402    /// Number of files present in the graph but absent from the manifest.
1403    pub manifest_gap: usize,
1404    /// `Some` only for an enum with member-level references; `None` for every
1405    /// other symbol.
1406    pub member_refs: Option<MemberRefs>,
1407}
1408
1409/// The outcome of a `refs` query: resolved, ambiguous, a bare-member answer,
1410/// or not found.
1411#[derive(Debug, Clone, PartialEq)]
1412pub enum RefsResult {
1413    /// Represents `Resolved`.
1414    Resolved(RefsModel),
1415    /// Represents `Ambiguous`.
1416    Ambiguous(Vec<String>),
1417    /// A bare-member answer: one resolved-shaped model per declaring type.
1418    Members(Vec<RefsModel>),
1419    /// Represents `NotFound`.
1420    NotFound,
1421}
1422
1423// A file's project is the first segment of its repo-relative path -- the graph
1424// carries no other grouping, and the paths it stores are always repo-relative
1425// with `/` separators. A file at the repo root has the empty project, which
1426// only ever matches another root file.
1427fn project_of(file: &str) -> &str {
1428    match file.find('/') {
1429        Some(i) => &file[..i],
1430        None => "",
1431    }
1432}
1433
1434// Trims exactly this ASCII set and nothing else. `str::trim` would strip the
1435// full Unicode White_Space property (U+00A0 included) but not U+FEFF; a fixed
1436// explicit set keeps trimming stable and independent of the Unicode tables, so
1437// a line whose first or last character is one of these is trimmed predictably.
1438fn trim_source(text: &str) -> &str {
1439    text.trim_matches(|c| matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0b}' | '\u{0c}'))
1440}
1441
1442/// Per-file cache of source lines, so a widely used type does not re-read one
1443/// consumer file once per hit. The value is `None` for a file that could not
1444/// be read.
1445pub type LineCache = HashMap<String, Option<Vec<String>>>;
1446
1447fn cached_line(root: &Path, file: &str, line: usize, cache: &mut LineCache) -> String {
1448    let lines = cache.entry(file.to_string()).or_insert_with(|| {
1449        std::fs::read_to_string(root.join(file))
1450            .ok()
1451            .map(|body| body.split('\n').map(str::to_string).collect())
1452    });
1453    let Some(lines) = lines else {
1454        return String::new();
1455    };
1456    if line < 1 || line > lines.len() {
1457        return String::new();
1458    }
1459    let mut raw = lines[line - 1].as_str();
1460    // A UTF-8 BOM survives `read_to_string` and is not in the trim set, so line
1461    // 1 of a BOM'd file would otherwise print it.
1462    if line == 1 {
1463        raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
1464    }
1465    trim_source(raw).to_string()
1466}
1467
1468// Interior tabs collapse to one space each and the result is cut to `SOURCE_MAX`.
1469fn clip_source(text: &str) -> String {
1470    let text = text.replace('\t', " ");
1471    // Cutting at `SOURCE_MAX` UTF-16 code units can split a surrogate pair; the
1472    // lone surrogate is then emitted as U+FFFD, which is what `from_utf16_lossy`
1473    // produces here.
1474    let units: Vec<u16> = text.encode_utf16().collect();
1475    if units.len() > SOURCE_MAX {
1476        String::from_utf16_lossy(&units[..SOURCE_MAX])
1477    } else {
1478        text
1479    }
1480}
1481
1482// The one trimmed, clipped line of source a refs hit sits on, so a caller can
1483// judge the hit without opening the file.
1484fn hit_source(root: &Path, file: &str, line: usize, cache: &mut LineCache) -> String {
1485    clip_source(&cached_line(root, file, line, cache))
1486}
1487
1488// A `uses-member` edge records the member's declaring TYPE, the file and the
1489// line, and never the member's own name, so no bare-member answer can be read
1490// off an edge. This whole-token test stands in for the field that is missing:
1491// an occurrence with a word character on either side does not count, so `Foo`
1492// never answers for a line whose only occurrence is `FooEx`. A word character
1493// is an ASCII letter, an ASCII digit or `_`, and nothing else -- every other
1494// code point, non-ASCII included, is a boundary. That rule is stated as a
1495// literal test rather than a character class so it does not depend on any
1496// regex engine's Unicode handling.
1497//
1498// The scan works over UTF-8 bytes: a valid UTF-8 needle can only match at a
1499// character boundary (lead and continuation byte ranges are disjoint), and
1500// every byte outside ASCII fails the word-character test on both sides.
1501fn is_word_byte(b: u8) -> bool {
1502    b.is_ascii_alphanumeric() || b == b'_'
1503}
1504
1505fn line_has_token(line: &str, token: &str) -> bool {
1506    let hay = line.as_bytes();
1507    let needle = token.as_bytes();
1508    if needle.is_empty() || needle.len() > hay.len() {
1509        return false;
1510    }
1511    for at in 0..=(hay.len() - needle.len()) {
1512        if &hay[at..at + needle.len()] != needle {
1513            continue;
1514        }
1515        let after_at = at + needle.len();
1516        let before_ok = at == 0 || !is_word_byte(hay[at - 1]);
1517        let after_ok = after_at >= hay.len() || !is_word_byte(hay[after_at]);
1518        if before_ok && after_ok {
1519            return true;
1520        }
1521    }
1522    false
1523}
1524
1525// Every type that declares `name`, in name-index order, each with the sites it
1526// declares it at. Two overloads are two sites on ONE type, never two
1527// candidates. Markup and resource rows carry no `owner`, so nothing a markup
1528// file names can be mistaken for a member.
1529fn member_owners(index: &GraphIndex, name: &str) -> Vec<(String, Vec<DefSite>)> {
1530    let mut out: Vec<(String, Vec<DefSite>)> = Vec::new();
1531    let mut at: HashMap<&str, usize> = HashMap::new();
1532    for n in &index.graph.names {
1533        if n.name != name || n.owner.is_empty() || !index.by_id.contains_key(&n.owner) {
1534            continue;
1535        }
1536        let site = DefSite {
1537            file: n.file.clone(),
1538            line: n.line,
1539        };
1540        match at.get(n.owner.as_str()) {
1541            Some(&i) => out[i].1.push(site),
1542            None => {
1543                at.insert(n.owner.as_str(), out.len());
1544                out.push((n.owner.clone(), vec![site]));
1545            }
1546        }
1547    }
1548    out
1549}
1550
1551// The two non-empty outcomes of `build_member_refs_models`: a plain models
1552// array, or an ambiguity -- more than one declaring type surviving edge-line
1553// verification is reported, never turned into several models.
1554enum MemberRefsOutcome {
1555    Models(Vec<RefsModel>),
1556    Ambiguous(Vec<String>),
1557}
1558
1559// `refs <bare member>`: the name index names the declaring type(s); each of
1560// that type's inbound `uses-member` edges survives only if the line it starts
1561// on carries the member as a whole token. A type whose edges all fail
1562// verification contributes no model at all, and when none survives the caller
1563// takes the zero-hit path.
1564//
1565// More than one declaring type surviving verification answers
1566// `Ambiguous(owner_ids)`, in name-index order, rather than several models --
1567// the house rule of never guessing between candidates. Overloads of one name
1568// on ONE type are one group (one entry in `owners`, several sites), never an
1569// ambiguity.
1570fn build_member_refs_models(
1571    index: &GraphIndex,
1572    name: &str,
1573    inbound_cap: usize,
1574) -> Option<MemberRefsOutcome> {
1575    let owners = member_owners(index, name);
1576    if owners.is_empty() {
1577        return None;
1578    }
1579    let edges = &index.graph.edges;
1580    let mut cache: LineCache = HashMap::new();
1581    let mut groups: Vec<(String, Vec<DefSite>, Vec<(usize, bool)>)> = Vec::new();
1582    for (owner, sites) in owners {
1583        let refs = symbol_refs(index, &owner);
1584        let owner_def = &index.graph.defs[index.by_id[&owner]];
1585        let owner_project = project_of(&owner_def.file).to_string();
1586        let mut kept: Vec<(usize, bool)> = Vec::new();
1587        for &e in &refs.inbound_uses_member {
1588            kept.push((e, false));
1589        }
1590        for &e in &refs.heuristic_inbound_uses_member {
1591            kept.push((e, true));
1592        }
1593        kept.retain(|&(e, _)| {
1594            let (file, line) = edge_loc(&edges[e]);
1595            line_has_token(&cached_line(&index.root, file, line, &mut cache), name)
1596        });
1597        // Same ranking a type's inbound table uses: precise before guessed, the
1598        // declaring type's own project before every other, then file, then
1599        // line.
1600        let foreign = |e: usize| usize::from(project_of(edge_loc(&edges[e]).0) != owner_project);
1601        kept.sort_by(|a, b| {
1602            a.1.cmp(&b.1)
1603                .then_with(|| foreign(a.0).cmp(&foreign(b.0)))
1604                .then_with(|| loc_cmp(&edges[a.0], &edges[b.0]))
1605        });
1606        if !kept.is_empty() {
1607            groups.push((owner, sites, kept));
1608        }
1609    }
1610    if groups.is_empty() {
1611        return None;
1612    }
1613    // Reported before the inbound cap is ever spent, so the answer does not
1614    // depend on `inbound_cap`: an ambiguity is a refusal, not a budgeted,
1615    // capped multi-block resolution.
1616    if groups.len() > 1 {
1617        return Some(MemberRefsOutcome::Ambiguous(
1618            groups.into_iter().map(|(owner, _, _)| owner).collect(),
1619        ));
1620    }
1621
1622    fn empty<R>() -> Table<R> {
1623        Table {
1624            total: 0,
1625            dropped: 0,
1626            rows: Vec::new(),
1627        }
1628    }
1629    let mut budget = inbound_cap;
1630    let mut models = Vec::new();
1631    for (owner, sites, kept) in groups {
1632        let take = budget.min(kept.len());
1633        budget -= take;
1634        let mut rows = Vec::new();
1635        for &(e, heuristic) in &kept[..take] {
1636            let (file, line) = edge_loc(&edges[e]);
1637            let source = clip_source(&cached_line(&index.root, file, line, &mut cache));
1638            rows.push(InboundRow {
1639                file: file.to_string(),
1640                line,
1641                heuristic,
1642                source,
1643            });
1644        }
1645        let total = kept.len();
1646        models.push(RefsModel {
1647            query: name.to_string(),
1648            id: format!("{owner}.{name}"),
1649            kind: "member".to_string(),
1650            sites,
1651            inbound: InboundTables {
1652                inherits: empty(),
1653                uses_type: empty(),
1654                uses_member: Table {
1655                    total,
1656                    dropped: total - rows.len(),
1657                    rows,
1658                },
1659            },
1660            outbound: None,
1661            ambiguous: AmbiguousTables {
1662                inbound: empty(),
1663                outbound: empty(),
1664            },
1665            manifest_gap: index.flagged_files.len(),
1666            member_refs: None,
1667        });
1668    }
1669    Some(MemberRefsOutcome::Models(models))
1670}
1671
1672/// One inbound edge awaiting the global cap: which kind's table it belongs to,
1673/// which edge it is, and whether it was guessed.
1674struct RankedInbound {
1675    kind: usize,
1676    edge: usize,
1677    heuristic: bool,
1678}
1679
1680/// One outbound edge awaiting the shared `--out` cap: which of the four kinds
1681/// it belongs to (0=inherits, 1=uses-type, 2=uses-member, 3=imports), which
1682/// edge it is, and whether it was guessed. `imports` is never a guess -- the
1683/// builder never marks one heuristic, by construction (see
1684/// `build_outbound_tables` below).
1685struct RankedOutbound {
1686    kind: usize,
1687    edge: usize,
1688    heuristic: bool,
1689}
1690
1691// The three ref kinds name a `to_file` -- ranked same-project/foreign against
1692// it, exactly as an inbound edge ranks its `from_file`. An imports edge names a
1693// namespace string, never a file, so nothing proves it shares the def's own
1694// project: it never earns the same-project rank and always sorts as foreign,
1695// the never-guess rule applied to ranking rather than to resolution.
1696fn outbound_foreign(def_project: &str, edges: &[graph::Edge], r: &RankedOutbound) -> usize {
1697    if r.kind == 3 {
1698        return 1;
1699    }
1700    let to_file = match &edges[r.edge] {
1701        graph::Edge::Inherits { to_file, .. }
1702        | graph::Edge::UsesType { to_file, .. }
1703        | graph::Edge::UsesMember { to_file, .. } => to_file.as_str(),
1704        _ => unreachable!(
1705            "outbound ranked kinds 0-2 only ever hold inherits/uses-type/uses-member edge indices"
1706        ),
1707    };
1708    usize::from(project_of(to_file) != def_project)
1709}
1710
1711// The four outbound kinds share ONE cap and ONE ranking under `--out`,
1712// mirroring `build_refs_model`'s own inbound block below: resolved before
1713// heuristic, then the def's own project before every other (imports always
1714// foreign, per `outbound_foreign` above), then file, then line. Each shown hit
1715// carries the same trimmed source line an inbound hit does, read at the SAME
1716// `from_line` an inbound row reads -- for an outbound edge that is a line in
1717// the def's own file, the site actually making the reference, not the caller's.
1718fn build_outbound_tables(
1719    refs: &SymbolRefs,
1720    def_project: &str,
1721    edges: &[graph::Edge],
1722    root: &Path,
1723    outbound_cap: usize,
1724) -> OutboundTables {
1725    let mut ranked: Vec<RankedOutbound> = Vec::new();
1726    for &e in &refs.outbound_inherits {
1727        ranked.push(RankedOutbound {
1728            kind: 0,
1729            edge: e,
1730            heuristic: false,
1731        });
1732    }
1733    for &e in &refs.heuristic_outbound_inherits {
1734        ranked.push(RankedOutbound {
1735            kind: 0,
1736            edge: e,
1737            heuristic: true,
1738        });
1739    }
1740    for &e in &refs.outbound_uses_type {
1741        ranked.push(RankedOutbound {
1742            kind: 1,
1743            edge: e,
1744            heuristic: false,
1745        });
1746    }
1747    for &e in &refs.heuristic_outbound_uses_type {
1748        ranked.push(RankedOutbound {
1749            kind: 1,
1750            edge: e,
1751            heuristic: true,
1752        });
1753    }
1754    for &e in &refs.outbound_uses_member {
1755        ranked.push(RankedOutbound {
1756            kind: 2,
1757            edge: e,
1758            heuristic: false,
1759        });
1760    }
1761    for &e in &refs.heuristic_outbound_uses_member {
1762        ranked.push(RankedOutbound {
1763            kind: 2,
1764            edge: e,
1765            heuristic: true,
1766        });
1767    }
1768    for &e in &refs.outbound_imports {
1769        ranked.push(RankedOutbound {
1770            kind: 3,
1771            edge: e,
1772            heuristic: false,
1773        });
1774    }
1775    let mut totals = [0usize; 4];
1776    for r in &ranked {
1777        totals[r.kind] += 1;
1778    }
1779    ranked.sort_by(|a, b| {
1780        a.heuristic
1781            .cmp(&b.heuristic)
1782            .then_with(|| {
1783                outbound_foreign(def_project, edges, a).cmp(&outbound_foreign(
1784                    def_project,
1785                    edges,
1786                    b,
1787                ))
1788            })
1789            .then_with(|| loc_cmp(&edges[a.edge], &edges[b.edge]))
1790    });
1791    let (shown, _) = cap_rows(ranked, outbound_cap);
1792
1793    let mut source_cache: LineCache = HashMap::new();
1794    let mut inherits = Vec::new();
1795    let mut uses_type = Vec::new();
1796    let mut uses_member = Vec::new();
1797    let mut imports = Vec::new();
1798    for r in shown {
1799        let (file, line) = edge_loc(&edges[r.edge]);
1800        let source = hit_source(root, file, line, &mut source_cache);
1801        if r.kind == 3 {
1802            let graph::Edge::Imports { target, .. } = &edges[r.edge] else {
1803                unreachable!("outbound kind 3 only ever holds imports edge indices");
1804            };
1805            imports.push(ImportRow {
1806                file: file.to_string(),
1807                line,
1808                target: target.clone(),
1809                source,
1810            });
1811            continue;
1812        }
1813        let (to_file, to) = match &edges[r.edge] {
1814            graph::Edge::Inherits { to_file, to, .. }
1815            | graph::Edge::UsesType { to_file, to, .. }
1816            | graph::Edge::UsesMember { to_file, to, .. } => (to_file.clone(), to.clone()),
1817            _ => unreachable!("outbound ranked kinds 0-2 only ever hold inherits/uses-type/uses-member edge indices"),
1818        };
1819        let row = OutboundRow {
1820            file: file.to_string(),
1821            line,
1822            to_file,
1823            to,
1824            heuristic: r.heuristic,
1825            source,
1826        };
1827        match r.kind {
1828            0 => inherits.push(row),
1829            1 => uses_type.push(row),
1830            _ => uses_member.push(row),
1831        }
1832    }
1833
1834    OutboundTables {
1835        inherits: Table {
1836            total: totals[0],
1837            dropped: totals[0] - inherits.len(),
1838            rows: inherits,
1839        },
1840        uses_type: Table {
1841            total: totals[1],
1842            dropped: totals[1] - uses_type.len(),
1843            rows: uses_type,
1844        },
1845        uses_member: Table {
1846            total: totals[2],
1847            dropped: totals[2] - uses_member.len(),
1848            rows: uses_member,
1849        },
1850        imports: Table {
1851            total: totals[3],
1852            dropped: totals[3] - imports.len(),
1853            rows: imports,
1854        },
1855    }
1856}
1857
1858/// Build the `refs` model for `query`. `out` includes the outbound tables;
1859/// `cap`/`inbound_cap`/`outbound_cap` bound the tables; `all_out` (`--all`)
1860/// lifts the inbound and outbound caps.
1861pub fn build_refs_model(
1862    index: &GraphIndex,
1863    query: &str,
1864    out: bool,
1865    cap: usize,
1866    inbound_cap: usize,
1867    outbound_cap: usize,
1868    all_out: bool,
1869) -> RefsResult {
1870    build_refs_model_inner(
1871        index,
1872        query,
1873        out,
1874        cap,
1875        inbound_cap,
1876        outbound_cap,
1877        all_out,
1878        false,
1879    )
1880}
1881
1882#[allow(clippy::too_many_arguments)]
1883fn build_refs_model_inner(
1884    index: &GraphIndex,
1885    query: &str,
1886    out: bool,
1887    cap: usize,
1888    inbound_cap: usize,
1889    outbound_cap: usize,
1890    all_out: bool,
1891    exclude_self_inbound: bool,
1892) -> RefsResult {
1893    let id = match resolve_symbol(index, query) {
1894        Resolution::Resolved(id) => id,
1895        Resolution::Ambiguous(ids) => return RefsResult::Ambiguous(ids),
1896        // The member path is a FALLBACK, reached only when nothing in the graph
1897        // declares `query` as a type, so a name that is both a type and a
1898        // member still answers as the type. `out` has no member reading (a
1899        // type's outbound edges are the type's answer, not the member's) and is
1900        // ignored here.
1901        Resolution::NotFound => {
1902            return match build_member_refs_models(index, query, inbound_cap) {
1903                Some(MemberRefsOutcome::Models(models)) => RefsResult::Members(models),
1904                Some(MemberRefsOutcome::Ambiguous(ids)) => RefsResult::Ambiguous(ids),
1905                None => RefsResult::NotFound,
1906            };
1907        }
1908    };
1909    let def_idx = *index
1910        .by_id
1911        .get(&id)
1912        .expect("resolved id must exist in the index it was resolved from");
1913    let def = &index.graph.defs[def_idx];
1914    let refs = symbol_refs(index, &id);
1915    let edges = &index.graph.edges;
1916
1917    // The three inbound kinds share ONE cap and ONE ranking, so a widely used
1918    // type spends its whole budget on the most specific edges it has rather
1919    // than on whichever kind the walk reached first. Rank: precise before
1920    // heuristic, then the def's own project before every other, then file,
1921    // then line. Push order (kind by kind, precise then heuristic within a
1922    // kind) is load-bearing: the sort is stable, so two edges of different
1923    // kinds at the same file:line keep their push order.
1924    let def_project = project_of(&def.file);
1925    let mut ranked: Vec<RankedInbound> = Vec::new();
1926    let mut totals = [0usize; 3];
1927    let is_self_inbound = |edge: usize| {
1928        let (file, line) = edge_loc(&edges[edge]);
1929        exclude_self_inbound
1930            && def.end_line >= def.line
1931            && file == def.file
1932            && (def.line..=def.end_line).contains(&line)
1933    };
1934    for (kind, (precise, heuristic)) in [
1935        (&refs.inbound_inherits, &refs.heuristic_inbound_inherits),
1936        (&refs.inbound_uses_type, &refs.heuristic_inbound_uses_type),
1937        (
1938            &refs.inbound_uses_member,
1939            &refs.heuristic_inbound_uses_member,
1940        ),
1941    ]
1942    .into_iter()
1943    .enumerate()
1944    {
1945        for &e in precise {
1946            if is_self_inbound(e) {
1947                continue;
1948            }
1949            ranked.push(RankedInbound {
1950                kind,
1951                edge: e,
1952                heuristic: false,
1953            });
1954            totals[kind] += 1;
1955        }
1956        for &e in heuristic {
1957            if is_self_inbound(e) {
1958                continue;
1959            }
1960            ranked.push(RankedInbound {
1961                kind,
1962                edge: e,
1963                heuristic: true,
1964            });
1965            totals[kind] += 1;
1966        }
1967    }
1968    let foreign = |e: usize| usize::from(project_of(edge_loc(&edges[e]).0) != def_project);
1969    ranked.sort_by(|a, b| {
1970        a.heuristic
1971            .cmp(&b.heuristic)
1972            .then_with(|| foreign(a.edge).cmp(&foreign(b.edge)))
1973            .then_with(|| loc_cmp(&edges[a.edge], &edges[b.edge]))
1974    });
1975    // `all_out` (`--all`, the same flag the outbound cap-lift below reads) lifts
1976    // the inbound cap too, not just the outbound one: a caller reaching for
1977    // `--all` on a truncated inbound table is asking for exactly this.
1978    let (shown_inbound, _) = cap_rows(ranked, if all_out { usize::MAX } else { inbound_cap });
1979
1980    let mut source_cache: LineCache = HashMap::new();
1981    let mut rows: [Vec<InboundRow>; 3] = [Vec::new(), Vec::new(), Vec::new()];
1982    for r in shown_inbound {
1983        let (file, line) = edge_loc(&edges[r.edge]);
1984        let source = hit_source(&index.root, file, line, &mut source_cache);
1985        rows[r.kind].push(InboundRow {
1986            file: file.to_string(),
1987            line,
1988            heuristic: r.heuristic,
1989            source,
1990        });
1991    }
1992    let [inherits_rows, uses_type_rows, uses_member_rows] = rows;
1993    let inbound_table = |total: usize, rows: Vec<InboundRow>| Table {
1994        total,
1995        dropped: total - rows.len(),
1996        rows,
1997    };
1998    let inbound = InboundTables {
1999        inherits: inbound_table(totals[0], inherits_rows),
2000        uses_type: inbound_table(totals[1], uses_type_rows),
2001        uses_member: inbound_table(totals[2], uses_member_rows),
2002    };
2003
2004    let outbound = out.then(|| {
2005        build_outbound_tables(
2006            &refs,
2007            def_project,
2008            edges,
2009            &index.root,
2010            if all_out { usize::MAX } else { outbound_cap },
2011        )
2012    });
2013    let ambiguous = AmbiguousTables {
2014        inbound: build_table(refs.ambiguous_inbound, edges, cap, ambiguous_row),
2015        outbound: build_table(refs.ambiguous_outbound, edges, cap, ambiguous_row),
2016    };
2017
2018    let member_refs = if def.kind == "enum" {
2019        enum_member_refs(index, &id)
2020    } else {
2021        None
2022    };
2023
2024    RefsResult::Resolved(RefsModel {
2025        query: query.to_string(),
2026        id: id.clone(),
2027        kind: def.kind.clone(),
2028        sites: def_sites(index, &id),
2029        inbound,
2030        outbound,
2031        ambiguous,
2032        manifest_gap: index.flagged_files.len(),
2033        member_refs,
2034    })
2035}
2036
2037// ============================================================================
2038// build_read_model -- the declaration span plus the same inbound answer.
2039// ============================================================================
2040
2041/// The declaration span of a resolved def: the file, its 1-based start and
2042/// end lines, and the VERBATIM source text of those lines (no trim, no clip
2043///
2044/// -- a span is quoted, not summarized). `end_line` is what tree-sitter
2045/// delimited as the whole declaration node; when the file has changed since
2046/// the map, `end_line` is clamped to the file's current last line rather
2047/// than guessed upward, and a file that shrank below the span's start yields
2048/// no span at all.
2049#[derive(Debug, Clone, PartialEq)]
2050pub struct ReadSpan {
2051    /// The file value.
2052    pub file: String,
2053    /// The start line value.
2054    pub start_line: usize,
2055    /// The end line value.
2056    pub end_line: usize,
2057    /// The source value.
2058    pub source: String,
2059}
2060
2061/// The resolved `read` result for one symbol: everything `refs` answers,
2062///
2063/// plus the declaration span when one is on record. `span` is `None` for a
2064/// def with no recorded end (TS defs, a graph written before end lines were
2065/// extracted) and never faked from a start line alone.
2066#[derive(Debug, Clone, PartialEq)]
2067pub struct ReadModel {
2068    /// The refs value.
2069    pub refs: RefsModel,
2070    /// The span value.
2071    pub span: Option<ReadSpan>,
2072}
2073
2074/// The outcome of a `read` query -- the `refs` outcome set unchanged, so the
2075/// ambiguity and zero-hit discipline are literally the same code path's.
2076#[derive(Debug, Clone, PartialEq)]
2077pub enum ReadResult {
2078    /// Represents `Resolved`.
2079    Resolved(Box<ReadModel>),
2080    /// A bare-member answer: one resolved-shaped model per declaring type.
2081    Members(Vec<RefsModel>),
2082    /// Represents `Ambiguous`.
2083    Ambiguous(Vec<String>),
2084    /// Represents `NotFound`.
2085    NotFound,
2086}
2087
2088// Slices the mapped span out of the current file text. Fails closed: an
2089// unreadable file or a span past EOF degrades to "no span", which the
2090// renderer shows as a start-line-only answer, never as invented text.
2091fn read_span(root: &Path, model: &RefsModel, end_line: usize) -> Option<ReadSpan> {
2092    let site = model.sites.first()?;
2093    if end_line < site.line {
2094        return None;
2095    }
2096    let body = std::fs::read_to_string(root.join(&site.file)).ok()?;
2097    let lines: Vec<&str> = body.split('\n').collect();
2098    if site.line < 1 || site.line > lines.len() {
2099        return None;
2100    }
2101    // The map may be behind the working tree; quoting past EOF would invent
2102    // lines, so the end clamps to what the file actually has.
2103    let end = end_line.min(lines.len());
2104    let mut start_text = lines[site.line - 1];
2105    if site.line == 1 {
2106        start_text = start_text.strip_prefix('\u{feff}').unwrap_or(start_text);
2107    }
2108    let mut source = String::from(start_text);
2109    for line in &lines[site.line..end] {
2110        source.push('\n');
2111        source.push_str(line);
2112    }
2113    Some(ReadSpan {
2114        file: site.file.clone(),
2115        start_line: site.line,
2116        end_line: end,
2117        source,
2118    })
2119}
2120
2121/// `read` = `refs` resolution + capped-and-ranked inbound machinery, reused
2122///
2123/// wholesale (`out` stays off -- the verb answers "what declares this and
2124/// what points at it"), plus the one new fact: the declaration span.
2125pub fn build_read_model(index: &GraphIndex, query: &str) -> ReadResult {
2126    match build_refs_model_inner(
2127        index,
2128        query,
2129        false,
2130        DEFAULT_CAP,
2131        INBOUND_CAP,
2132        OUTBOUND_CAP,
2133        false,
2134        true,
2135    ) {
2136        RefsResult::Resolved(m) => {
2137            let end_line = index.def(&m.id).map(|d| d.end_line).unwrap_or(0);
2138            let span = if end_line > 0 {
2139                read_span(&index.root, &m, end_line)
2140            } else {
2141                None
2142            };
2143            ReadResult::Resolved(Box::new(ReadModel { refs: m, span }))
2144        }
2145        RefsResult::Members(models) => ReadResult::Members(models),
2146        RefsResult::Ambiguous(ids) => ReadResult::Ambiguous(ids),
2147        RefsResult::NotFound => ReadResult::NotFound,
2148    }
2149}
2150
2151// ============================================================================
2152// build_tests_model -- test coverage.
2153// ============================================================================
2154
2155/// One row of a tests model: a test `file`, the test-carrying `test_defs` in
2156/// it, the `lines` at which it references the symbol, the `ref_count`, and
2157/// whether the reference was guessed (`heuristic`).
2158#[derive(Debug, Clone, PartialEq)]
2159pub struct TestRow {
2160    /// The file value.
2161    pub file: String,
2162    /// The test defs value.
2163    pub test_defs: Vec<String>,
2164    /// The lines value.
2165    pub lines: Vec<usize>,
2166    /// The ref count value.
2167    pub ref_count: usize,
2168    /// The heuristic value.
2169    pub heuristic: bool,
2170}
2171
2172/// The resolved `tests` result for one symbol.
2173#[derive(Debug, Clone, PartialEq)]
2174pub struct TestsModel {
2175    /// The query value.
2176    pub query: String,
2177    /// The symbol value.
2178    pub symbol: String,
2179    /// The def files value.
2180    pub def_files: Vec<String>,
2181    /// Precise rows first, heuristic rows after -- the same discipline every
2182    /// other consumer keeps, so a guess never sits inside the list of facts.
2183    pub rows: Vec<TestRow>,
2184    /// The test file count value.
2185    pub test_file_count: usize,
2186    /// The ref count value.
2187    pub ref_count: usize,
2188    /// The heuristic file count value.
2189    pub heuristic_file_count: usize,
2190    /// The heuristic ref count value.
2191    pub heuristic_ref_count: usize,
2192}
2193
2194/// The outcome of a `tests` query: resolved, ambiguous, or not found.
2195#[derive(Debug, Clone, PartialEq)]
2196pub enum TestsResult {
2197    /// Represents `Resolved`.
2198    Resolved(TestsModel),
2199    /// Represents `Ambiguous`.
2200    Ambiguous(Vec<String>),
2201    /// Represents `NotFound`.
2202    NotFound,
2203}
2204
2205// The three inbound kinds of one adjacency, walked in `REF_KINDS` order.
2206fn collect_test_rows(index: &GraphIndex, kinds: [&[usize]; 3], heuristic: bool) -> Vec<TestRow> {
2207    let mut rows: Vec<TestRow> = Vec::new();
2208    let mut by_file: HashMap<String, usize> = HashMap::new();
2209    for idxs in kinds {
2210        for &i in idxs {
2211            let (from_file, from_line) = edge_loc(&index.graph.edges[i]);
2212            let Some(test_defs) = index.test_defs_by_file.get(from_file) else {
2213                continue;
2214            };
2215            let slot = match by_file.get(from_file) {
2216                Some(&slot) => slot,
2217                None => {
2218                    let slot = rows.len();
2219                    rows.push(TestRow {
2220                        file: from_file.to_string(),
2221                        test_defs: test_defs
2222                            .iter()
2223                            .map(|&d| index.graph.defs[d].id.clone())
2224                            .collect(),
2225                        lines: Vec::new(),
2226                        ref_count: 0,
2227                        heuristic,
2228                    });
2229                    by_file.insert(from_file.to_string(), slot);
2230                    slot
2231                }
2232            };
2233            rows[slot].lines.push(from_line);
2234            rows[slot].ref_count += 1;
2235        }
2236    }
2237    for row in &mut rows {
2238        row.lines.sort_unstable();
2239    }
2240    rows
2241}
2242
2243/// Which TEST files reference this symbol, at which lines, via which
2244/// test-carrying defs. Nothing here is a new kind of edge: it is the same
2245/// inbound set `refs` reports, filtered to the files `test_defs_by_file`
2246/// vouches for, which is why a symbol nothing tests answers "none" rather than
2247/// falling back to a name convention.
2248///
2249/// Row order is FIRST-SEEN edge order, kind by kind (not sorted), so the file a
2250/// consumer reads first is the one the graph reached first; the edge array
2251/// walked is itself built in a pinned order.
2252pub fn build_tests_model(index: &GraphIndex, query: &str) -> TestsResult {
2253    let id = match resolve_symbol(index, query) {
2254        Resolution::Resolved(id) => id,
2255        Resolution::Ambiguous(ids) => return TestsResult::Ambiguous(ids),
2256        Resolution::NotFound => return TestsResult::NotFound,
2257    };
2258    let refs = symbol_refs(index, &id);
2259
2260    let precise = collect_test_rows(
2261        index,
2262        [
2263            &refs.inbound_inherits,
2264            &refs.inbound_uses_type,
2265            &refs.inbound_uses_member,
2266        ],
2267        false,
2268    );
2269    let heuristic = collect_test_rows(
2270        index,
2271        [
2272            &refs.heuristic_inbound_inherits,
2273            &refs.heuristic_inbound_uses_type,
2274            &refs.heuristic_inbound_uses_member,
2275        ],
2276        true,
2277    );
2278
2279    let test_file_count = precise.len();
2280    let ref_count: usize = precise.iter().map(|r| r.ref_count).sum();
2281    let heuristic_file_count = heuristic.len();
2282    let heuristic_ref_count: usize = heuristic.iter().map(|r| r.ref_count).sum();
2283
2284    let mut rows = precise;
2285    rows.extend(heuristic);
2286
2287    TestsResult::Resolved(TestsModel {
2288        query: query.to_string(),
2289        symbol: id.clone(),
2290        def_files: def_files(index, &id),
2291        rows,
2292        test_file_count,
2293        ref_count,
2294        heuristic_file_count,
2295        heuristic_ref_count,
2296    })
2297}
2298
2299// ============================================================================
2300// impact_walk + personalized_page_rank.
2301// ============================================================================
2302
2303/// One representative referencing line PER EDGE KIND that reached this file,
2304/// the lowest line per kind. `0` means "this kind never contributed", which
2305/// keeps a row a kind never touched free of that key in `--json`. `direct_amb`
2306/// is the ambiguous half of the `direct` kind, kept apart only so
2307/// `build_impact_model` can apply the resolved-over-ambiguous tie-break instead
2308/// of letting an ambiguous line win by being numerically smaller.
2309#[derive(Debug, Clone, Default, PartialEq)]
2310pub struct KindLines {
2311    /// The direct value.
2312    pub direct: usize,
2313    /// The direct amb value.
2314    pub direct_amb: usize,
2315    /// The ctor di value.
2316    pub ctor_di: usize,
2317    /// The heuristic value.
2318    pub heuristic: usize,
2319    /// The iface value.
2320    pub iface: usize,
2321}
2322
2323// The lowest-line-wins guard applied at each of the walk's five edge sites.
2324// Every edge of one kind reaching one file shares that file by construction, so
2325// the minimum line is a total order that depends on nothing about iteration.
2326fn note_line(slot: &mut usize, line: usize) {
2327    if line > 0 && (*slot == 0 || line < *slot) {
2328        *slot = line;
2329    }
2330}
2331
2332#[derive(Debug, Clone, Default)]
2333struct Hit {
2334    via_count: u32,
2335    ambiguous_count: u32,
2336    heuristic_count: u32,
2337    symbols: SeqSet<String>,
2338    // The `via` labels an interface-hop hit at this file carries
2339    // (`"IFoo (ctor-di)"` or bare `"IFoo"`), first-seen order.
2340    iface_via: SeqSet<String>,
2341    // One representative line per edge kind (see `KindLines`).
2342    lines: KindLines,
2343}
2344
2345/// One visited file's entry in an impact walk.
2346#[derive(Debug, Clone, PartialEq)]
2347pub struct VisitedEntry {
2348    /// The hop value.
2349    pub hop: u32,
2350    /// The via count value.
2351    pub via_count: u32,
2352    /// The ambiguous count value.
2353    pub ambiguous_count: u32,
2354    /// The heuristic count value.
2355    pub heuristic_count: u32,
2356    /// The symbols value.
2357    pub symbols: Vec<String>,
2358    /// The interface `via` labels for this file's hits.
2359    pub iface_via: Vec<String>,
2360    /// One representative line per edge kind that reached this file.
2361    pub lines: KindLines,
2362    /// This file is a hub, so the walk recorded it as an affected file and
2363    /// stopped there instead of expanding through it.
2364    pub infra: bool,
2365}
2366
2367/// An interface the walk refused to widen through, and how many distinct
2368/// constructors inject it. Sorted widest-first then by name, a total order that
2369/// depends on nothing about the walk's own iteration.
2370#[derive(Debug, Clone, PartialEq)]
2371pub struct BrakedIface {
2372    /// The iface value.
2373    pub iface: String,
2374    /// The fanin value.
2375    pub fanin: usize,
2376}
2377
2378/// A hub file the walk refused to widen THROUGH, and how many distinct other
2379/// files reference it. Kept in its own vec (separate from [`BrakedIface`]) so
2380/// that type, its tests and its JSON bytes are untouched when the file brake
2381/// never fires. Sorted widest-first then by path, the same total order, for the
2382/// same reason.
2383#[derive(Debug, Clone, PartialEq)]
2384pub struct BrakedFile {
2385    /// The file value.
2386    pub file: String,
2387    /// The indegree value.
2388    pub indegree: usize,
2389}
2390
2391/// The result of an impact walk.
2392pub struct ImpactWalkResult {
2393    /// Visited files in discovery order across hops (the first hop a file is
2394    /// hit wins its slot). `build_impact_model` reads these keys in order to
2395    /// build the PPR `nodes` array, so this order is directly
2396    /// float-accumulation-order-visible downstream (see module header).
2397    pub visited: SeqMap<VisitedEntry>,
2398    /// The file-level subgraph in the blast-radius direction. A plain
2399    /// `HashMap<_, HashSet<_>>` because -- unlike `visited`/`frontier` -- an
2400    /// out-edge set's iteration order does NOT affect
2401    /// `personalized_page_rank`'s float accumulation (each target in one node's
2402    /// out-set gets exactly one independent `+=`), so no ordering wrapper is
2403    /// needed here.
2404    pub fwd_adj: HashMap<String, HashSet<String>>,
2405    /// The seed files, in `seed_ids` order flat-mapped through `def_files`.
2406    pub seed_files: SeqSet<String>,
2407    /// The interfaces the walk refused to widen through.
2408    pub braked: Vec<BrakedIface>,
2409    /// The hub files the walk refused to widen through.
2410    pub braked_files: Vec<BrakedFile>,
2411}
2412
2413fn add_adj(fwd_adj: &mut HashMap<String, HashSet<String>>, from: &str, to: &str) {
2414    fwd_adj
2415        .entry(from.to_string())
2416        .or_default()
2417        .insert(to.to_string());
2418}
2419
2420/// Reverse-edge k-hop walk from a set of seed def ids. Hop N's frontier is
2421/// every def declared in a file discovered at hop N-1; a file already visited
2422/// (or a seed's own file) is recorded at most once, at its first (minimum) hop.
2423/// Ambiguous edges whose candidates include a frontier def count toward
2424/// via-count/top-symbols too. As a byproduct it builds `fwd_adj`, the
2425/// file-level subgraph that seeds the PPR ranking pass.
2426///
2427/// `iface` (the CLI's `--no-iface`, inverted) gates the interface hop: it is
2428/// def-id-matched, never name-matched, so `infra`/`ambiguous` ctor-di edges can
2429/// never widen anything. `iface_max_fanin` brakes the same hop for a BROAD
2430/// interface: one whose ctor-di in-degree (`ctor_di_fanin`, distinct
2431/// constructor sites) exceeds the threshold is treated as infrastructure for
2432/// widening purposes, whatever it is named. Both widening paths are braked
2433/// together -- a contract injected everywhere is also NAMED everywhere, so
2434/// braking one alone leaves the radius just as wide. `0` disables the brake and
2435/// restores unbraked widening.
2436pub fn impact_walk(
2437    index: &GraphIndex,
2438    seed_ids: &[String],
2439    hops: u32,
2440    iface: bool,
2441    iface_max_fanin: usize,
2442    hub_max_indegree: usize,
2443) -> ImpactWalkResult {
2444    let mut visited: SeqMap<VisitedEntry> = SeqMap::new();
2445    // Interface name -> its fan-in, for every interface this walk actually
2446    // refused to widen through. Recorded so the narrowing is REPORTED, never
2447    // silent.
2448    let mut braked: HashMap<String, usize> = HashMap::new();
2449    // File -> its in-degree, for every hub file this walk refused to widen
2450    // THROUGH. Recorded on the same terms as `braked` above.
2451    let mut braked_files: HashMap<String, usize> = HashMap::new();
2452    let mut seed_files: SeqSet<String> = SeqSet::new();
2453    for id in seed_ids {
2454        for f in def_files(index, id) {
2455            seed_files.insert(f);
2456        }
2457    }
2458
2459    let mut fwd_adj: HashMap<String, HashSet<String>> = HashMap::new();
2460
2461    let mut frontier: SeqSet<String> = SeqSet::new();
2462    for id in seed_ids {
2463        frontier.insert(id.clone());
2464    }
2465    let mut seen_defs: HashSet<String> = seed_ids.iter().cloned().collect();
2466
2467    let mut hop = 1u32;
2468    while hop <= hops && frontier.len() > 0 {
2469        let mut hits: SeqMap<Hit> = SeqMap::new();
2470
2471        for def_id in frontier.iter() {
2472            if let Some(inb) = index.inbound.get(def_id) {
2473                for kind_edges in [&inb.inherits, &inb.uses_type, &inb.uses_member] {
2474                    for &ei in kind_edges {
2475                        let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2476                        let from_file = loc_file.to_string();
2477                        {
2478                            let h = hits.get_or_insert_default(&from_file);
2479                            h.via_count += 1;
2480                            h.symbols.insert(def_id.clone());
2481                            note_line(&mut h.lines.direct, loc_line);
2482                        }
2483                        for sf in def_files(index, def_id) {
2484                            add_adj(&mut fwd_adj, &sf, &from_file);
2485                        }
2486                    }
2487                }
2488            }
2489            if let Some(amb_idxs) = index.ambiguous_by_candidate.get(def_id) {
2490                for &ei in amb_idxs {
2491                    let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2492                    let from_file = loc_file.to_string();
2493                    {
2494                        let h = hits.get_or_insert_default(&from_file);
2495                        h.ambiguous_count += 1;
2496                        note_line(&mut h.lines.direct_amb, loc_line);
2497                    }
2498                    for sf in def_files(index, def_id) {
2499                        add_adj(&mut fwd_adj, &sf, &from_file);
2500                    }
2501                }
2502            }
2503            // The interface hop. `seen_sites` dedupes a ctor-injected
2504            // parameter's own COMPANION plain `uses-type` ref (every ctor-param
2505            // ref emits one, resolving to the INTERFACE's own def at the
2506            // identical from_file/from_line) against the direct-reference pass
2507            // just below, so a ctor-injecting consumer is counted once,
2508            // labelled via ctor-di, never twice.
2509            if iface {
2510                let mut seen_sites: HashSet<(String, usize)> = HashSet::new();
2511                if let Some(ctor_idxs) = index.ctor_di_by_to.get(def_id) {
2512                    for &ei in ctor_idxs {
2513                        let graph::Edge::CtorDi {
2514                            from_file,
2515                            from_line,
2516                            iface: iface_name,
2517                            ..
2518                        } = &index.graph.edges[ei]
2519                        else {
2520                            continue;
2521                        };
2522                        // A braked edge is skipped WHOLE: its site never enters
2523                        // `seen_sites` either. It does not need to; the same
2524                        // interface is braked below too.
2525                        if brake_fanin(index, iface_max_fanin, iface_name, &mut braked) {
2526                            continue;
2527                        }
2528                        let from_file = from_file.clone();
2529                        {
2530                            let h = hits.get_or_insert_default(&from_file);
2531                            h.via_count += 1;
2532                            h.symbols.insert(def_id.clone());
2533                            h.iface_via.insert(format!("{iface_name} (ctor-di)"));
2534                            note_line(&mut h.lines.ctor_di, *from_line);
2535                        }
2536                        seen_sites.insert((from_file.clone(), *from_line));
2537                        for sf in def_files(index, def_id) {
2538                            add_adj(&mut fwd_adj, &sf, &from_file);
2539                        }
2540                    }
2541                }
2542                for iface_id in implemented_interfaces(index, def_id) {
2543                    let Some(iface_inb) = index.inbound.get(&iface_id) else {
2544                        continue;
2545                    };
2546                    let iface_name = index
2547                        .def(&iface_id)
2548                        .map(|d| d.name.clone())
2549                        .unwrap_or_else(|| iface_id.clone());
2550                    if brake_fanin(index, iface_max_fanin, &iface_name, &mut braked) {
2551                        continue;
2552                    }
2553                    // 'inherits' is deliberately excluded here: a SIBLING
2554                    // implementor of the same interface owns its own
2555                    // base-list edge to it, which is a fact about the
2556                    // sibling, never a reference to THIS class.
2557                    for kind_edges in [&iface_inb.uses_type, &iface_inb.uses_member] {
2558                        for &ei in kind_edges {
2559                            let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2560                            let site = (loc_file.to_string(), loc_line);
2561                            if seen_sites.contains(&site) {
2562                                continue;
2563                            }
2564                            let from_file = site.0.clone();
2565                            seen_sites.insert(site);
2566                            {
2567                                let h = hits.get_or_insert_default(&from_file);
2568                                h.via_count += 1;
2569                                h.symbols.insert(def_id.clone());
2570                                h.iface_via.insert(iface_name.clone());
2571                                note_line(&mut h.lines.iface, loc_line);
2572                            }
2573                            for sf in def_files(index, def_id) {
2574                                add_adj(&mut fwd_adj, &sf, &from_file);
2575                            }
2576                        }
2577                    }
2578                }
2579            }
2580            // A heuristic edge REACHES a file and stops there.
2581            // It never calls `add_adj` (so the PageRank graph, and therefore
2582            // every precise row's score and ordering, is bit-for-bit what it
2583            // was) and its file never enters `next_frontier` below, so the walk
2584            // cannot continue THROUGH a guess. One guess is a guess; a guess
2585            // used as the premise of the next hop is compound fiction, and
2586            // blast radius is exactly the answer people act on.
2587            if let Some(hinb) = index.heuristic_inbound.get(def_id) {
2588                for kind_edges in [&hinb.inherits, &hinb.uses_type, &hinb.uses_member] {
2589                    for &ei in kind_edges {
2590                        let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2591                        let from_file = loc_file.to_string();
2592                        let h = hits.get_or_insert_default(&from_file);
2593                        h.heuristic_count += 1;
2594                        h.symbols.insert(def_id.clone());
2595                        note_line(&mut h.lines.heuristic, loc_line);
2596                    }
2597                }
2598            }
2599        }
2600
2601        let mut next_frontier: SeqSet<String> = SeqSet::new();
2602        for (file, h) in hits.iter() {
2603            // A hub is a file the rest of the estate refers to BY JOB rather
2604            // than by dependency. Two independent halves: the path-pattern
2605            // classification, which is always on, and the in-degree threshold,
2606            // which `0` disables. A hub file is still an AFFECTED file: it is
2607            // recorded as a row like any other, carrying `class: infra` so the
2608            // narrowing is visible on the row itself and not only in the
2609            // trailer.
2610            let indegree = index.hub_indegree.get(file).copied().unwrap_or(0);
2611            let hub = is_infra_file(file) || (hub_max_indegree > 0 && indegree >= hub_max_indegree);
2612            if !visited.contains_key(file) && !seed_files.contains(file) {
2613                visited.insert(
2614                    file.clone(),
2615                    VisitedEntry {
2616                        hop,
2617                        via_count: h.via_count,
2618                        ambiguous_count: h.ambiguous_count,
2619                        heuristic_count: h.heuristic_count,
2620                        symbols: h.symbols.clone().into_vec(),
2621                        iface_via: h.iface_via.clone().into_vec(),
2622                        lines: h.lines.clone(),
2623                        infra: hub,
2624                    },
2625                );
2626            }
2627            // Reached ONLY by heuristic edges: recorded above as an affected
2628            // file, never expanded. A file with even one precise or ambiguous
2629            // hit expands exactly as it always did.
2630            if h.via_count == 0 && h.ambiguous_count == 0 {
2631                continue;
2632            }
2633            // The brake, at the SAME point in the walk a broad interface is
2634            // excluded: the hop that produced this file still fired, and every
2635            // interface path still fired. What stops here is expansion THROUGH
2636            // the hub. Reported only when the walk was actually going to expand
2637            // (`hop < hops`); on the last hop nothing expands, so nothing was
2638            // held back and a trailer naming this file would claim a narrowing
2639            // that never happened.
2640            if hub {
2641                if hop < hops {
2642                    braked_files.insert(file.clone(), indegree);
2643                }
2644                continue;
2645            }
2646            if let Some(def_idxs) = index.by_file.get(file) {
2647                for &di in def_idxs {
2648                    let def_id = &index.graph.defs[di].id;
2649                    if !seen_defs.contains(def_id) {
2650                        seen_defs.insert(def_id.clone());
2651                        next_frontier.insert(def_id.clone());
2652                    }
2653                }
2654            }
2655        }
2656        frontier = next_frontier;
2657        hop += 1;
2658    }
2659
2660    // Sorted widest-first, then by name -- a total order that depends on
2661    // nothing about the walk's own iteration, so the output is deterministic
2662    // here without needing an ordered set.
2663    let mut braked: Vec<BrakedIface> = braked
2664        .into_iter()
2665        .map(|(iface, fanin)| BrakedIface { iface, fanin })
2666        .collect();
2667    braked.sort_by(|a, b| b.fanin.cmp(&a.fanin).then_with(|| a.iface.cmp(&b.iface)));
2668    let mut braked_files: Vec<BrakedFile> = braked_files
2669        .into_iter()
2670        .map(|(file, indegree)| BrakedFile { file, indegree })
2671        .collect();
2672    braked_files.sort_by(|a, b| {
2673        b.indegree
2674            .cmp(&a.indegree)
2675            .then_with(|| a.file.cmp(&b.file))
2676    });
2677
2678    ImpactWalkResult {
2679        visited,
2680        fwd_adj,
2681        seed_files,
2682        braked,
2683        braked_files,
2684    }
2685}
2686
2687// Returns true when this injected type is braked, recording it on the way out
2688// -- the single point of decision, so no widening path can skip an interface
2689// without also declaring it.
2690fn brake_fanin(
2691    index: &GraphIndex,
2692    iface_max_fanin: usize,
2693    name: &str,
2694    braked: &mut HashMap<String, usize>,
2695) -> bool {
2696    if iface_max_fanin == 0 {
2697        return false;
2698    }
2699    let fanin = index.ctor_di_fanin.get(name).copied().unwrap_or(0);
2700    if fanin <= iface_max_fanin {
2701        return false;
2702    }
2703    braked.insert(name.to_string(), fanin);
2704    true
2705}
2706
2707/// Personalized PageRank over the file-level subgraph. Plain power iteration,
2708/// no dependencies. Ranking only -- never removes a node.
2709///
2710/// **Bit-exactness**: `nodes`' array order drives `idx` (node -> array
2711/// position) and the `for i in 0..n` iteration order of the main loop, which
2712/// accumulates into `next[k]` via non-associative `f64` addition (both the
2713/// per-edge `next[j] += share` and, especially, the dangling-node
2714/// redistribution `next[k] += damping * r * teleport[k]` summed across every
2715/// dangling `i`). A different `nodes` order can therefore produce a
2716/// bit-different (not wrong) result -- callers MUST build `nodes` in a fixed
2717/// order (`seed_files` then `visited` keys, deduped) for a stable result; see
2718/// `build_impact_model`. Iterating one node's own out-edge set
2719/// (`fwd_adj.get(id)`) is NOT order-sensitive: every target in that set is
2720/// distinct, so each gets exactly one independent `+=` regardless of scan order
2721/// -- `fwd_adj`'s plain `HashMap<_, HashSet<_>>` is therefore safe.
2722pub fn personalized_page_rank(
2723    nodes: &[String],
2724    fwd_adj: &HashMap<String, HashSet<String>>,
2725    seeds: &[String],
2726    damping: f64,
2727    iterations: u32,
2728) -> HashMap<String, f64> {
2729    let n = nodes.len();
2730    if n == 0 {
2731        return HashMap::new();
2732    }
2733    let idx: HashMap<&str, usize> = nodes
2734        .iter()
2735        .enumerate()
2736        .map(|(i, s)| (s.as_str(), i))
2737        .collect();
2738
2739    let mut teleport = vec![0f64; n];
2740    let mut wsum = 0f64;
2741    for s in seeds {
2742        if let Some(&i) = idx.get(s.as_str()) {
2743            teleport[i] = 1.0;
2744            wsum += 1.0;
2745        }
2746    }
2747    if wsum == 0.0 {
2748        let v = 1.0 / n as f64;
2749        for t in teleport.iter_mut() {
2750            *t = v;
2751        }
2752    } else {
2753        for t in teleport.iter_mut() {
2754            *t /= wsum;
2755        }
2756    }
2757
2758    let out_lists: Vec<Vec<usize>> = nodes
2759        .iter()
2760        .map(|id| match fwd_adj.get(id) {
2761            None => Vec::new(),
2762            Some(s) => s
2763                .iter()
2764                .filter_map(|t| idx.get(t.as_str()).copied())
2765                .collect(),
2766        })
2767        .collect();
2768
2769    let mut rank = teleport.clone();
2770    for _ in 0..iterations {
2771        let mut next = vec![0f64; n];
2772        for i in 0..n {
2773            let r = rank[i];
2774            if r == 0.0 {
2775                continue;
2776            }
2777            let outs = &out_lists[i];
2778            if outs.is_empty() {
2779                for k in 0..n {
2780                    next[k] += damping * r * teleport[k]; // dangling node: no rank leakage
2781                }
2782                continue;
2783            }
2784            let share = (damping * r) / outs.len() as f64;
2785            for &j in outs {
2786                next[j] += share;
2787            }
2788        }
2789        for k in 0..n {
2790            next[k] += (1.0 - damping) * teleport[k];
2791        }
2792        rank = next;
2793    }
2794
2795    let mut result = HashMap::with_capacity(n);
2796    for (i, id) in nodes.iter().enumerate() {
2797        result.insert(id.clone(), rank[i]);
2798    }
2799    result
2800}
2801
2802// ============================================================================
2803// resolve_impact_seed + build_impact_model.
2804// ============================================================================
2805
2806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2807/// Represents `SeedKind`.
2808pub enum SeedKind {
2809    /// Represents `File`.
2810    File,
2811    /// Represents `Symbol`.
2812    Symbol,
2813}
2814
2815/// Whether `arg` looks like a file path rather than a symbol query: a bare
2816/// simple/qualified name with no path separator or file extension is a symbol
2817/// query; anything else is a file path. Equivalent to `/\.[A-Za-z0-9]+$/` by
2818/// checking the LAST `.` only (if an earlier dot's suffix were all-alnum, the
2819/// last dot's suffix -- which contains that earlier dot -- could not be, since
2820/// `.` is not `[A-Za-z0-9]`). Note a fully-qualified symbol id like
2821/// `App.Widgets.IWidget` (trailing segment alphanumeric) DOES look like a file
2822/// path under this heuristic.
2823pub fn looks_like_file_path(arg: &str) -> bool {
2824    if arg.contains('/') {
2825        return true;
2826    }
2827    match arg.rfind('.') {
2828        Some(pos) => {
2829            let ext = &arg[pos + 1..];
2830            !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
2831        }
2832        None => false,
2833    }
2834}
2835
2836#[derive(Debug, Clone, PartialEq)]
2837/// Represents `SeedResolution`.
2838pub enum SeedResolution {
2839    /// The value value.
2840    Resolved {
2841        /// The resolved seed kind.
2842        kind: SeedKind,
2843        /// The resolved definition identifiers.
2844        ids: Vec<String>,
2845    },
2846    /// The value value.
2847    Ambiguous {
2848        /// The seed kind.
2849        kind: SeedKind,
2850        /// The candidate definition identifiers.
2851        ids: Vec<String>,
2852    },
2853    /// The value value.
2854    NotFound {
2855        /// The inferred seed kind.
2856        kind: SeedKind,
2857    },
2858}
2859
2860/// Resolve an impact-walk seed argument to a file's defs or a single symbol.
2861pub fn resolve_impact_seed(index: &GraphIndex, arg: &str) -> SeedResolution {
2862    if looks_like_file_path(arg) {
2863        return match index.by_file.get(arg) {
2864            Some(ids) if !ids.is_empty() => SeedResolution::Resolved {
2865                kind: SeedKind::File,
2866                ids: ids
2867                    .iter()
2868                    .map(|&i| index.graph.defs[i].id.clone())
2869                    .collect(),
2870            },
2871            _ => SeedResolution::NotFound {
2872                kind: SeedKind::File,
2873            },
2874        };
2875    }
2876    match resolve_symbol(index, arg) {
2877        Resolution::Resolved(id) => SeedResolution::Resolved {
2878            kind: SeedKind::Symbol,
2879            ids: vec![id],
2880        },
2881        Resolution::Ambiguous(ids) => SeedResolution::Ambiguous {
2882            kind: SeedKind::Symbol,
2883            ids,
2884        },
2885        Resolution::NotFound => SeedResolution::NotFound {
2886            kind: SeedKind::Symbol,
2887        },
2888    }
2889}
2890
2891/// One row of an impact model: a file in the blast radius and its metrics.
2892#[derive(Debug, Clone, PartialEq)]
2893pub struct ImpactRow {
2894    /// The file value.
2895    pub file: String,
2896    /// The hop value.
2897    pub hop: u32,
2898    /// The via count value.
2899    pub via_count: u32,
2900    /// The ambiguous count value.
2901    pub ambiguous_count: u32,
2902    /// The top symbols value.
2903    pub top_symbols: Vec<String>,
2904    /// The top symbols more value.
2905    pub top_symbols_more: usize,
2906    /// The score value.
2907    pub score: f64,
2908    /// "heuristic" is a property of how the file was REACHED, not of the edges
2909    /// that reached it: one precise or ambiguous hit makes the file an ordinary
2910    /// affected file no matter how many guesses also point at it. Only a file
2911    /// reached EXCLUSIVELY by guesses is flagged. On a precise row neither this
2912    /// nor `heuristic` is emitted in `--json`.
2913    pub heuristic_count: u32,
2914    /// The heuristic value.
2915    pub heuristic: bool,
2916    /// Present only on a row the interface hop actually reached, empty (and
2917    /// omitted from `--json`) on every other row.
2918    pub iface_via: Vec<String>,
2919    /// One representative referencing line per EDGE KIND that actually reached
2920    /// this file, in the fixed order `direct, ctor-di, heuristic, iface`; a
2921    /// kind that never contributed is absent, and a row no kind could attribute
2922    /// a line to carries no entry at all. The file is the row's own `file` --
2923    /// every edge folded into one row is an edge OUT OF that file -- so a line
2924    /// alone locates the site.
2925    pub from_lines: Vec<(&'static str, usize)>,
2926    /// Set only on a hub file. The row is still an affected file; this says the
2927    /// walk stopped THERE rather than continuing through it. `false` means the
2928    /// key is absent in `--json`.
2929    pub infra: bool,
2930}
2931
2932/// The resolved `impact` result for one seed.
2933#[derive(Debug, Clone, PartialEq)]
2934pub struct ImpactModel {
2935    /// The kind value.
2936    pub kind: SeedKind,
2937    /// The seed files value.
2938    pub seed_files: Vec<String>,
2939    /// The hops value.
2940    pub hops: u32,
2941    /// The total affected value.
2942    pub total_affected: usize,
2943    /// The rows value.
2944    pub rows: Vec<ImpactRow>,
2945    /// The dropped value.
2946    pub dropped: usize,
2947    /// The manifest gap value.
2948    pub manifest_gap: usize,
2949    /// Number of files reached exclusively by guesses. Counted over EVERY row
2950    /// the walk found, capped or not.
2951    pub heuristic_affected: usize,
2952    /// How many of the PRECISELY affected files carry a test-flagged def.
2953    /// Counted over every affected row, not just the capped `shown` slice, and
2954    /// heuristic-only rows are excluded: "a guess also touched a test file" is
2955    /// not coverage. Zero is the interesting answer -- a blast radius that
2956    /// reaches no test file at all is a gap the caller can act on.
2957    pub tests_affected: usize,
2958    /// The braked interfaces, present only when the brake actually fired (empty
2959    /// under `--iface-max-fanin 0` or `--no-iface`).
2960    pub braked: Vec<BrakedIface>,
2961    /// The braked hub files, reported alongside `braked`.
2962    pub braked_files: Vec<BrakedFile>,
2963}
2964
2965/// The outcome of an `impact` query: resolved, ambiguous, or not found.
2966#[derive(Debug, Clone, PartialEq)]
2967pub enum ImpactResult {
2968    /// Represents `Resolved`.
2969    Resolved(ImpactModel),
2970    /// The value value.
2971    Ambiguous {
2972        /// The seed kind.
2973        kind: SeedKind,
2974        /// The candidate definition identifiers.
2975        ids: Vec<String>,
2976    },
2977    /// The value value.
2978    NotFound {
2979        /// The inferred seed kind.
2980        kind: SeedKind,
2981    },
2982}
2983
2984// Assembles a row's per-kind representative lines. The resolved-over-ambiguous
2985// tie-break decides the `direct` kind: a resolved site outranks an ambiguous
2986// one, and the ambiguous line is used only when the resolved half never fired.
2987fn from_lines_of(lines: &KindLines) -> Vec<(&'static str, usize)> {
2988    let mut out: Vec<(&'static str, usize)> = Vec::new();
2989    let direct = if lines.direct != 0 {
2990        lines.direct
2991    } else {
2992        lines.direct_amb
2993    };
2994    if direct != 0 {
2995        out.push(("direct", direct));
2996    }
2997    if lines.ctor_di != 0 {
2998        out.push(("ctor-di", lines.ctor_di));
2999    }
3000    if lines.heuristic != 0 {
3001        out.push(("heuristic", lines.heuristic));
3002    }
3003    if lines.iface != 0 {
3004        out.push(("iface", lines.iface));
3005    }
3006    out
3007}
3008
3009/// Blast radius for a seed, ranked. Never filters beyond the stated `cap`:
3010/// `rows` is the ranked, capped view; `dropped`/`total_affected` account for
3011/// every row the walk found, capped or not.
3012pub fn build_impact_model(
3013    index: &GraphIndex,
3014    arg: &str,
3015    hops: u32,
3016    cap: usize,
3017    iface: bool,
3018    iface_max_fanin: usize,
3019    hub_max_indegree: usize,
3020) -> ImpactResult {
3021    let (kind, seed_ids) = match resolve_impact_seed(index, arg) {
3022        SeedResolution::Resolved { kind, ids } => (kind, ids),
3023        SeedResolution::Ambiguous { kind, ids } => return ImpactResult::Ambiguous { kind, ids },
3024        SeedResolution::NotFound { kind } => return ImpactResult::NotFound { kind },
3025    };
3026
3027    let walk = impact_walk(
3028        index,
3029        &seed_ids,
3030        hops,
3031        iface,
3032        iface_max_fanin,
3033        hub_max_indegree,
3034    );
3035
3036    // `seed_files` then `visited` keys, deduped -- this order is
3037    // float-accumulation-order-visible in `personalized_page_rank`.
3038    let mut node_set: SeqSet<String> = SeqSet::new();
3039    for f in walk.seed_files.iter() {
3040        node_set.insert(f.clone());
3041    }
3042    for f in walk.visited.keys() {
3043        node_set.insert(f.clone());
3044    }
3045    let nodes = node_set.into_vec();
3046    let seeds: Vec<String> = walk.seed_files.iter().cloned().collect();
3047
3048    let rank = personalized_page_rank(
3049        &nodes,
3050        &walk.fwd_adj,
3051        &seeds,
3052        DEFAULT_DAMPING,
3053        DEFAULT_ITERATIONS,
3054    );
3055
3056    let mut rows: Vec<ImpactRow> = walk
3057        .visited
3058        .iter()
3059        .map(|(file, h)| {
3060            let names: Vec<String> = h
3061                .symbols
3062                .iter()
3063                .map(|id| {
3064                    index
3065                        .def(id)
3066                        .map(|d| d.name.clone())
3067                        .unwrap_or_else(|| id.clone())
3068                })
3069                .collect();
3070            let top_symbols: Vec<String> = names.iter().take(3).cloned().collect();
3071            let top_symbols_more = names.len().saturating_sub(3);
3072            let heuristic = h.via_count == 0 && h.ambiguous_count == 0;
3073            ImpactRow {
3074                file: file.clone(),
3075                hop: h.hop,
3076                via_count: h.via_count,
3077                ambiguous_count: h.ambiguous_count,
3078                top_symbols,
3079                top_symbols_more,
3080                score: *rank.get(file).unwrap_or(&0.0),
3081                heuristic_count: if heuristic { h.heuristic_count } else { 0 },
3082                heuristic,
3083                iface_via: h.iface_via.clone(),
3084                from_lines: from_lines_of(&h.lines),
3085                infra: h.infra,
3086            }
3087        })
3088        .collect();
3089
3090    // Sort key: heuristic-only rows AFTER every precise row, then by descending
3091    // score, then hop, then file. Heuristic-only rows sort last regardless of
3092    // rank -- the CLI contract is positional, and PageRank never saw the
3093    // heuristic edges, so their scores are not comparable with the precise ones
3094    // in the first place.
3095    rows.sort_by(|a, b| {
3096        a.heuristic
3097            .cmp(&b.heuristic)
3098            .then_with(|| {
3099                b.score
3100                    .partial_cmp(&a.score)
3101                    .unwrap_or(std::cmp::Ordering::Equal)
3102            })
3103            .then_with(|| a.hop.cmp(&b.hop))
3104            .then_with(|| a.file.cmp(&b.file))
3105    });
3106
3107    let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
3108    let total_affected = rows.len() - heuristic_affected;
3109    let tests_affected = rows
3110        .iter()
3111        .filter(|r| !r.heuristic && index.test_defs_by_file.contains_key(&r.file))
3112        .count();
3113    let (shown, dropped) = cap_rows(rows, cap);
3114
3115    ImpactResult::Resolved(ImpactModel {
3116        kind,
3117        seed_files: walk.seed_files.into_vec(),
3118        hops,
3119        total_affected,
3120        rows: shown,
3121        dropped,
3122        manifest_gap: index.flagged_files.len(),
3123        heuristic_affected,
3124        tests_affected,
3125        braked: walk.braked,
3126        braked_files: walk.braked_files,
3127    })
3128}
3129
3130// ============================================================================
3131// Tests -- fixture-level behavioral contract. Each test builds its
3132// `graph::Graph` directly in memory (no JSON round-trip needed:
3133// `load_graph_index` takes an already-parsed `&Graph`) and writes only a
3134// manifest.json fixture to a temp `.git`-shaped dir (the one piece
3135// `load_graph_index` still reads from disk).
3136// ============================================================================
3137
3138#[cfg(test)]
3139mod tests {
3140    use super::*;
3141    use std::fs;
3142    use std::path::PathBuf;
3143    use std::sync::atomic::{AtomicU64, Ordering};
3144    use std::time::{SystemTime, UNIX_EPOCH};
3145
3146    static COUNTER: AtomicU64 = AtomicU64::new(0);
3147
3148    fn temp_repo_root(label: &str) -> PathBuf {
3149        let nanos = SystemTime::now()
3150            .duration_since(UNIX_EPOCH)
3151            .unwrap()
3152            .as_nanos();
3153        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
3154        let dir = std::env::temp_dir().join(format!("scout-query-test-{label}-{nanos}-{n}"));
3155        fs::create_dir_all(dir.join(".git")).unwrap();
3156        dir
3157    }
3158
3159    /// Mirrors `fixtureRoot`'s manifest half: every file in `manifestFiles`
3160    /// gets `{purpose:'x', mtime:1, source:'ast'}`.
3161    fn write_manifest_fixture(root: &Path, files: &[&str]) {
3162        let dir = root.join(".git").join("scout");
3163        fs::create_dir_all(&dir).unwrap();
3164        let mut entries = serde_json::Map::new();
3165        for f in files {
3166            entries.insert(
3167                (*f).to_string(),
3168                serde_json::json!({"purpose": "x", "mtime": 1, "source": "ast"}),
3169            );
3170        }
3171        let manifest = serde_json::json!({"built_at_head": "deadbeef", "scoped_dirs": ["."], "entries": entries});
3172        fs::write(
3173            dir.join("manifest.json"),
3174            serde_json::to_string(&manifest).unwrap(),
3175        )
3176        .unwrap();
3177    }
3178
3179    fn dummy_stats() -> graph::Stats {
3180        graph::Stats {
3181            def_count: 0,
3182            file_count: 0,
3183            edges_by_kind: graph::EdgesByKind::default(),
3184            ambiguous_count: 0,
3185            ambiguous_pct: graph::Percent1::zero(),
3186            unresolved_external_count: 0,
3187            heuristic_edge_count: 0,
3188            test_def_count: 0,
3189            // Incidental: `ts` has no bearing on this C#-only query-layer
3190            // fixture.
3191            ts: None,
3192        }
3193    }
3194
3195    fn make_graph(defs: Vec<graph::Def>, edges: Vec<graph::Edge>) -> graph::Graph {
3196        graph::Graph {
3197            schema_version: 1,
3198            built_at_head: Some("deadbeef".to_string()),
3199            defs,
3200            edges,
3201            stats: dummy_stats(),
3202            names: Vec::new(),
3203        }
3204    }
3205
3206    fn def(
3207        id: &str,
3208        name: &str,
3209        namespace: &str,
3210        kind: &str,
3211        file: &str,
3212        line: usize,
3213    ) -> graph::Def {
3214        graph::Def {
3215            id: id.into(),
3216            name: name.into(),
3217            namespace: namespace.into(),
3218            kind: kind.into(),
3219            file: file.into(),
3220            line,
3221            methods: vec![],
3222            test_methods: vec![],
3223            also_in: vec![],
3224            end_line: 0,
3225        }
3226    }
3227
3228    fn def_also(
3229        id: &str,
3230        name: &str,
3231        namespace: &str,
3232        kind: &str,
3233        file: &str,
3234        line: usize,
3235        also_in: Vec<(&str, usize)>,
3236    ) -> graph::Def {
3237        graph::Def {
3238            id: id.into(),
3239            name: name.into(),
3240            namespace: namespace.into(),
3241            kind: kind.into(),
3242            file: file.into(),
3243            line,
3244            methods: vec![],
3245            test_methods: vec![],
3246            also_in: also_in
3247                .into_iter()
3248                .map(|(f, l)| graph::AlsoIn {
3249                    file: f.into(),
3250                    line: l,
3251                })
3252                .collect(),
3253            end_line: 0,
3254        }
3255    }
3256
3257    fn inherits(from_file: &str, from_line: usize, to: &str, to_file: &str) -> graph::Edge {
3258        graph::Edge::Inherits {
3259            from_file: from_file.into(),
3260            from_line,
3261            to: to.into(),
3262            to_file: to_file.into(),
3263            heuristic: false,
3264        }
3265    }
3266    fn uses_type(from_file: &str, from_line: usize, to: &str, to_file: &str) -> graph::Edge {
3267        graph::Edge::UsesType {
3268            from_file: from_file.into(),
3269            from_line,
3270            to: to.into(),
3271            to_file: to_file.into(),
3272            heuristic: false,
3273        }
3274    }
3275    fn uses_member(from_file: &str, from_line: usize, to: &str, to_file: &str) -> graph::Edge {
3276        graph::Edge::UsesMember {
3277            from_file: from_file.into(),
3278            from_line,
3279            to: to.into(),
3280            to_file: to_file.into(),
3281            heuristic: false,
3282        }
3283    }
3284    // The same edge, tagged as a guess -- the only difference the query layer
3285    // is allowed to see.
3286    fn heuristic_uses_member(
3287        from_file: &str,
3288        from_line: usize,
3289        to: &str,
3290        to_file: &str,
3291    ) -> graph::Edge {
3292        graph::Edge::UsesMember {
3293            from_file: from_file.into(),
3294            from_line,
3295            to: to.into(),
3296            to_file: to_file.into(),
3297            heuristic: true,
3298        }
3299    }
3300    fn heuristic_uses_type(
3301        from_file: &str,
3302        from_line: usize,
3303        to: &str,
3304        to_file: &str,
3305    ) -> graph::Edge {
3306        graph::Edge::UsesType {
3307            from_file: from_file.into(),
3308            from_line,
3309            to: to.into(),
3310            to_file: to_file.into(),
3311            heuristic: true,
3312        }
3313    }
3314    fn imports(from_file: &str, from_line: usize, target: &str) -> graph::Edge {
3315        graph::Edge::Imports {
3316            from_file: from_file.into(),
3317            from_line,
3318            target: target.into(),
3319        }
3320    }
3321    fn ambiguous(
3322        from_file: &str,
3323        from_line: usize,
3324        raw: &str,
3325        candidates: Vec<(&str, &str)>,
3326    ) -> graph::Edge {
3327        let candidate_count = candidates.len();
3328        graph::Edge::Ambiguous {
3329            origin: "uses-type".into(),
3330            from_file: from_file.into(),
3331            from_line,
3332            raw: raw.into(),
3333            candidates: candidates
3334                .into_iter()
3335                .map(|(id, file)| graph::Candidate {
3336                    id: id.into(),
3337                    file: file.into(),
3338                })
3339                .collect(),
3340            candidate_count,
3341        }
3342    }
3343
3344    // --- file_inbound_counts -------------------------------------------------
3345    //
3346    // The map `find`'s tie-break reads. Every claim it makes about which edges
3347    // are facts is pinned here: one wrong kind in (or out) silently reorders
3348    // every text-tied find answer.
3349
3350    #[test]
3351    fn file_inbound_counts_counts_precise_reference_edges_by_target_file() {
3352        let g = make_graph(
3353            vec![def(
3354                "App.IWidget",
3355                "IWidget",
3356                "App",
3357                "interface",
3358                "Widgets/IWidget.cs",
3359                3,
3360            )],
3361            vec![
3362                inherits("Widgets/Impl/A.cs", 5, "App.IWidget", "Widgets/IWidget.cs"),
3363                uses_type("Consumers/B.cs", 4, "App.IWidget", "Widgets/IWidget.cs"),
3364                uses_member("Consumers/C.cs", 9, "App.IWidget", "Widgets/IWidget.cs"),
3365            ],
3366        );
3367        let counts = file_inbound_counts(&g);
3368        assert_eq!(
3369            counts.get("Widgets/IWidget.cs"),
3370            Some(&3),
3371            "every precise reference kind counts"
3372        );
3373        assert_eq!(counts.len(), 1);
3374    }
3375
3376    #[test]
3377    fn file_inbound_counts_excludes_heuristic_edges_and_non_reference_kinds() {
3378        let g = make_graph(
3379            vec![def(
3380                "App.IWidget",
3381                "IWidget",
3382                "App",
3383                "interface",
3384                "Widgets/IWidget.cs",
3385                3,
3386            )],
3387            vec![
3388                heuristic_uses_type("Guessy/Guesser.cs", 7, "App.IWidget", "Widgets/IWidget.cs"),
3389                heuristic_uses_member("Guessy/Guesser2.cs", 8, "App.IWidget", "Widgets/IWidget.cs"),
3390                // Names a namespace, never a definition.
3391                imports("Widgets/IWidget.cs", 1, "App.Widgets"),
3392                // DI wiring and an unresolved name: neither is a reference to
3393                // this file's definitions.
3394                graph::Edge::CtorDi {
3395                    from_file: "App/Program.cs".into(),
3396                    from_line: 4,
3397                    iface: "App.IWidget".into(),
3398                    resolution: "plain".into(),
3399                    args: None,
3400                    to: None,
3401                    candidates: vec![],
3402                },
3403                ambiguous(
3404                    "Ambig/User.cs",
3405                    6,
3406                    "IWidget",
3407                    vec![("App.IWidget", "Widgets/IWidget.cs")],
3408                ),
3409                // A module import that resolves to the file itself is still an
3410                // import, not a reference to a declaration.
3411                graph::Edge::Import {
3412                    from_file: "Consumers/Importer.ts".into(),
3413                    from_line: 1,
3414                    target: "./widgets".into(),
3415                    to_file: "Widgets/IWidget.ts".into(),
3416                    via: None,
3417                },
3418            ],
3419        );
3420        assert!(
3421            file_inbound_counts(&g).is_empty(),
3422            "guesses, imports, ctor-di wiring, and ambiguity earn no count"
3423        );
3424    }
3425
3426    #[test]
3427    fn file_inbound_counts_counts_the_ts_reference_kinds() {
3428        // On a TS repo call/jsx-use/dispatch ARE the reference graph; a count
3429        // blind to them would rank every TS file at zero.
3430        let g = make_graph(
3431            vec![def(
3432                "ui.Button",
3433                "Button",
3434                "",
3435                "function",
3436                "src/Button.tsx",
3437                1,
3438            )],
3439            vec![
3440                graph::Edge::Call {
3441                    from_file: "src/App.ts".into(),
3442                    from_line: 10,
3443                    to: "ui.Button".into(),
3444                    to_file: "src/Button.tsx".into(),
3445                },
3446                graph::Edge::JsxUse {
3447                    from_file: "src/Page.tsx".into(),
3448                    from_line: 20,
3449                    to: "ui.Button".into(),
3450                    to_file: "src/Button.tsx".into(),
3451                },
3452                graph::Edge::Dispatch {
3453                    from_file: "src/store.ts".into(),
3454                    from_line: 30,
3455                    to: "ui.Button".into(),
3456                    to_file: "src/Button.tsx".into(),
3457                },
3458            ],
3459        );
3460        let counts = file_inbound_counts(&g);
3461        assert_eq!(counts.get("src/Button.tsx"), Some(&3));
3462        assert!(
3463            !counts.contains_key("src/App.ts"),
3464            "keyed by the TARGET file only"
3465        );
3466    }
3467
3468    #[test]
3469    fn file_inbound_counts_excludes_a_files_references_to_itself() {
3470        // This repository treats only references from other files as inbound
3471        // interest; a file's self-references do not count.
3472        let g = make_graph(
3473            vec![def("App.Hub", "Hub", "App", "class", "src/Hub.cs", 3)],
3474            vec![
3475                uses_type("src/Hub.cs", 5, "App.Hub", "src/Hub.cs"),
3476                inherits("src/Hub.cs", 7, "App.Hub", "src/Hub.cs"),
3477                uses_type("src/Other.cs", 9, "App.Hub", "src/Hub.cs"),
3478            ],
3479        );
3480        let counts = file_inbound_counts(&g);
3481        assert_eq!(
3482            counts.get("src/Hub.cs"),
3483            Some(&1),
3484            "self-references earn nothing"
3485        );
3486        assert_eq!(counts.len(), 1);
3487    }
3488
3489    #[test]
3490    fn file_inbound_counts_on_an_edgeless_graph_is_an_empty_map() {
3491        assert!(file_inbound_counts(&make_graph(vec![], vec![])).is_empty());
3492    }
3493
3494    // --- base fixture ---
3495
3496    const BASE_MANIFEST_FILES: &[&str] = &[
3497        "Widgets/IWidget.cs",
3498        "Widgets/Impl/WidgetImpl.cs",
3499        "Widgets/Impl/OtherImpl.cs",
3500        "Consumers/TwoHop.cs",
3501        "Consumers/Holder.cs",
3502        "One/Config.cs",
3503        "Two/Config.cs",
3504        "Outer/Container.cs",
3505        "Outer/Container.Extra.cs",
3506        "Three/Consumer.cs",
3507        // Ghost/NotInManifest.cs deliberately omitted.
3508    ];
3509
3510    fn base_fixture_graph() -> graph::Graph {
3511        make_graph(
3512            vec![
3513                def(
3514                    "App.Widgets.IWidget",
3515                    "IWidget",
3516                    "App.Widgets",
3517                    "interface",
3518                    "Widgets/IWidget.cs",
3519                    3,
3520                ),
3521                def(
3522                    "App.Widgets.Impl.WidgetImpl",
3523                    "WidgetImpl",
3524                    "App.Widgets.Impl",
3525                    "class",
3526                    "Widgets/Impl/WidgetImpl.cs",
3527                    5,
3528                ),
3529                def(
3530                    "App.Widgets.Impl.OtherImpl",
3531                    "OtherImpl",
3532                    "App.Widgets.Impl",
3533                    "class",
3534                    "Widgets/Impl/OtherImpl.cs",
3535                    5,
3536                ),
3537                def(
3538                    "App.Consumers.TwoHop",
3539                    "TwoHop",
3540                    "App.Consumers",
3541                    "class",
3542                    "Consumers/TwoHop.cs",
3543                    3,
3544                ),
3545                def(
3546                    "App.One.Config",
3547                    "Config",
3548                    "App.One",
3549                    "class",
3550                    "One/Config.cs",
3551                    1,
3552                ),
3553                def(
3554                    "App.Two.Config",
3555                    "Config",
3556                    "App.Two",
3557                    "class",
3558                    "Two/Config.cs",
3559                    1,
3560                ),
3561                def_also(
3562                    "App.Outer.Container",
3563                    "Container",
3564                    "App.Outer",
3565                    "class",
3566                    "Outer/Container.cs",
3567                    3,
3568                    vec![("Outer/Container.Extra.cs", 1)],
3569                ),
3570                def(
3571                    "App.Outer.Container+Item",
3572                    "Item",
3573                    "App.Outer",
3574                    "class",
3575                    "Outer/Container.cs",
3576                    5,
3577                ),
3578                def(
3579                    "App.Ghost.NotInManifest",
3580                    "NotInManifest",
3581                    "App.Ghost",
3582                    "class",
3583                    "Ghost/NotInManifest.cs",
3584                    1,
3585                ),
3586            ],
3587            vec![
3588                inherits(
3589                    "Widgets/Impl/WidgetImpl.cs",
3590                    5,
3591                    "App.Widgets.IWidget",
3592                    "Widgets/IWidget.cs",
3593                ),
3594                inherits(
3595                    "Widgets/Impl/OtherImpl.cs",
3596                    5,
3597                    "App.Widgets.IWidget",
3598                    "Widgets/IWidget.cs",
3599                ),
3600                uses_type(
3601                    "Consumers/Holder.cs",
3602                    8,
3603                    "App.Widgets.IWidget",
3604                    "Widgets/IWidget.cs",
3605                ),
3606                uses_type(
3607                    "Consumers/TwoHop.cs",
3608                    4,
3609                    "App.Widgets.Impl.WidgetImpl",
3610                    "Widgets/Impl/WidgetImpl.cs",
3611                ),
3612                imports("Widgets/Impl/WidgetImpl.cs", 1, "App.Widgets"),
3613                ambiguous(
3614                    "Three/Consumer.cs",
3615                    4,
3616                    "Config",
3617                    vec![
3618                        ("App.One.Config", "One/Config.cs"),
3619                        ("App.Two.Config", "Two/Config.cs"),
3620                    ],
3621                ),
3622            ],
3623        )
3624    }
3625
3626    fn base_fixture_root() -> PathBuf {
3627        let root = temp_repo_root("base");
3628        write_manifest_fixture(&root, BASE_MANIFEST_FILES);
3629        root
3630    }
3631
3632    fn enum_fixture_graph() -> graph::Graph {
3633        make_graph(
3634            vec![
3635                def(
3636                    "App.Enums.PostType",
3637                    "PostType",
3638                    "App.Enums",
3639                    "enum",
3640                    "Enums/PostType.cs",
3641                    3,
3642                ),
3643                def(
3644                    "App.Enums.PostType.Post",
3645                    "Post",
3646                    "App.Enums",
3647                    "enum-member",
3648                    "Enums/PostType.cs",
3649                    5,
3650                ),
3651                def(
3652                    "App.Enums.PostType.Question",
3653                    "Question",
3654                    "App.Enums",
3655                    "enum-member",
3656                    "Enums/PostType.cs",
3657                    6,
3658                ),
3659                def(
3660                    "App.Consumers.Reader",
3661                    "Reader",
3662                    "App.Consumers",
3663                    "class",
3664                    "Consumers/Reader.cs",
3665                    3,
3666                ),
3667                def(
3668                    "App.Consumers.TwoHop",
3669                    "TwoHop",
3670                    "App.Consumers",
3671                    "class",
3672                    "Consumers/TwoHop.cs",
3673                    3,
3674                ),
3675            ],
3676            vec![
3677                uses_member(
3678                    "Consumers/Reader.cs",
3679                    8,
3680                    "App.Enums.PostType.Question",
3681                    "Enums/PostType.cs",
3682                ),
3683                uses_type(
3684                    "Consumers/TwoHop.cs",
3685                    4,
3686                    "App.Consumers.Reader",
3687                    "Consumers/Reader.cs",
3688                ),
3689            ],
3690        )
3691    }
3692
3693    fn enum_fixture_root() -> PathBuf {
3694        let root = temp_repo_root("enum");
3695        write_manifest_fixture(
3696            &root,
3697            &[
3698                "Enums/PostType.cs",
3699                "Consumers/Reader.cs",
3700                "Consumers/TwoHop.cs",
3701            ],
3702        );
3703        root
3704    }
3705
3706    // --- 1: load_graph_index + manifest flagging ---
3707
3708    #[test]
3709    fn load_graph_index_joins_def_files_against_the_manifest_and_flags_a_graph_file_missing_from_it(
3710    ) {
3711        let graph = base_fixture_graph();
3712        let root = base_fixture_root();
3713        let index = load_graph_index(&graph, &root);
3714        assert!(
3715            index.by_id.contains_key("App.Ghost.NotInManifest"),
3716            "the def must still be indexed, not dropped"
3717        );
3718        assert!(
3719            index.flagged_files.contains("Ghost/NotInManifest.cs"),
3720            "its file must be flagged as missing from the manifest"
3721        );
3722        assert_eq!(
3723            index.flagged_files.len(),
3724            1,
3725            "every other def/edge file is in the manifest and must not be flagged"
3726        );
3727        assert!(index.manifest_present);
3728    }
3729
3730    // --- 2: resolve_symbol ladder ---
3731
3732    #[test]
3733    fn resolve_symbol_exact_id_unique_name_case_insensitive_ambiguous_notfound() {
3734        let graph = base_fixture_graph();
3735        let root = base_fixture_root();
3736        let index = load_graph_index(&graph, &root);
3737
3738        assert_eq!(
3739            resolve_symbol(&index, "App.Outer.Container+Item"),
3740            Resolution::Resolved("App.Outer.Container+Item".into())
3741        );
3742        assert_eq!(
3743            resolve_symbol(&index, "IWidget"),
3744            Resolution::Resolved("App.Widgets.IWidget".into())
3745        );
3746        assert_eq!(
3747            resolve_symbol(&index, "iwidget"),
3748            Resolution::Resolved("App.Widgets.IWidget".into()),
3749            "case-insensitive unique match must resolve"
3750        );
3751
3752        match resolve_symbol(&index, "Config") {
3753            Resolution::Ambiguous(mut ids) => {
3754                ids.sort();
3755                assert_eq!(
3756                    ids,
3757                    vec!["App.One.Config".to_string(), "App.Two.Config".to_string()]
3758                );
3759            }
3760            other => panic!("expected Ambiguous, got {other:?}"),
3761        }
3762
3763        assert_eq!(
3764            resolve_symbol(&index, "NoSuchSymbolAnywhere"),
3765            Resolution::NotFound
3766        );
3767    }
3768
3769    // --- 3: build_refs_model basic inbound/outbound/imports/manifest-gap ---
3770
3771    #[test]
3772    fn build_refs_model_def_site_inbound_outbound_grouped_imports_manifest_gap() {
3773        let graph = base_fixture_graph();
3774        let root = base_fixture_root();
3775        let index = load_graph_index(&graph, &root);
3776        let model = match build_refs_model(
3777            &index,
3778            "IWidget",
3779            true,
3780            DEFAULT_CAP,
3781            INBOUND_CAP,
3782            OUTBOUND_CAP,
3783            false,
3784        ) {
3785            RefsResult::Resolved(m) => m,
3786            other => panic!("expected Resolved, got {other:?}"),
3787        };
3788
3789        assert_eq!(model.id, "App.Widgets.IWidget");
3790        assert_eq!(
3791            model.sites,
3792            vec![DefSite {
3793                file: "Widgets/IWidget.cs".into(),
3794                line: 3
3795            }]
3796        );
3797
3798        assert_eq!(model.inbound.inherits.total, 2);
3799        let mut files: Vec<&str> = model
3800            .inbound
3801            .inherits
3802            .rows
3803            .iter()
3804            .map(|r| r.file.as_str())
3805            .collect();
3806        files.sort();
3807        assert_eq!(
3808            files,
3809            vec!["Widgets/Impl/OtherImpl.cs", "Widgets/Impl/WidgetImpl.cs"]
3810        );
3811        assert_eq!(model.inbound.uses_type.total, 1);
3812        assert_eq!(model.inbound.uses_type.rows[0].file, "Consumers/Holder.cs");
3813
3814        // IWidget's own file makes no outbound reference in the fixture.
3815        assert_eq!(
3816            model
3817                .outbound
3818                .as_ref()
3819                .expect("built with out=true")
3820                .inherits
3821                .total,
3822            0
3823        );
3824        assert_eq!(
3825            model
3826                .outbound
3827                .as_ref()
3828                .expect("built with out=true")
3829                .imports
3830                .total,
3831            0
3832        );
3833
3834        assert_eq!(
3835            model.manifest_gap, 1,
3836            "the one flagged def file must surface in every refs call, not just the loader"
3837        );
3838    }
3839
3840    // --- 4: outbound inherits+imports from own file; partial-class def sites ---
3841
3842    #[test]
3843    fn build_refs_model_outbound_own_file_and_partial_class_sites() {
3844        let graph = base_fixture_graph();
3845        let root = base_fixture_root();
3846        let index = load_graph_index(&graph, &root);
3847
3848        let impl_model = match build_refs_model(
3849            &index,
3850            "WidgetImpl",
3851            true,
3852            DEFAULT_CAP,
3853            INBOUND_CAP,
3854            OUTBOUND_CAP,
3855            false,
3856        ) {
3857            RefsResult::Resolved(m) => m,
3858            other => panic!("expected Resolved, got {other:?}"),
3859        };
3860        assert_eq!(
3861            impl_model
3862                .outbound
3863                .as_ref()
3864                .expect("built with out=true")
3865                .inherits
3866                .total,
3867            1
3868        );
3869        assert_eq!(
3870            impl_model
3871                .outbound
3872                .as_ref()
3873                .expect("built with out=true")
3874                .inherits
3875                .rows[0]
3876                .to_file,
3877            "Widgets/IWidget.cs"
3878        );
3879        assert_eq!(
3880            impl_model
3881                .outbound
3882                .as_ref()
3883                .expect("built with out=true")
3884                .imports
3885                .total,
3886            1
3887        );
3888        assert_eq!(
3889            impl_model
3890                .outbound
3891                .as_ref()
3892                .expect("built with out=true")
3893                .imports
3894                .rows[0]
3895                .target,
3896            "App.Widgets"
3897        );
3898        assert_eq!(
3899            impl_model.inbound.uses_type.total, 1,
3900            "TwoHop.cs references WidgetImpl"
3901        );
3902
3903        let container = match build_refs_model(
3904            &index,
3905            "App.Outer.Container",
3906            true,
3907            DEFAULT_CAP,
3908            INBOUND_CAP,
3909            OUTBOUND_CAP,
3910            false,
3911        ) {
3912            RefsResult::Resolved(m) => m,
3913            other => panic!("expected Resolved, got {other:?}"),
3914        };
3915        assert_eq!(
3916            container.sites,
3917            vec![
3918                DefSite {
3919                    file: "Outer/Container.cs".into(),
3920                    line: 3
3921                },
3922                DefSite {
3923                    file: "Outer/Container.Extra.cs".into(),
3924                    line: 1
3925                }
3926            ]
3927        );
3928    }
3929
3930    // --- 5: partial class, second site same file as first -- no outbound double-count ---
3931
3932    #[test]
3933    fn build_refs_model_partial_class_same_file_second_site_no_double_count() {
3934        let graph = make_graph(
3935            vec![def_also(
3936                "App.Split.Combo",
3937                "Combo",
3938                "App.Split",
3939                "class",
3940                "Split/Combo.cs",
3941                3,
3942                vec![("Split/Combo.cs", 20)],
3943            )],
3944            vec![
3945                uses_type("Split/Combo.cs", 5, "App.Split.Combo", "Split/Combo.cs"),
3946                imports("Split/Combo.cs", 1, "System"),
3947            ],
3948        );
3949        let root = temp_repo_root("partial-same-file");
3950        write_manifest_fixture(&root, &["Split/Combo.cs"]);
3951        let index = load_graph_index(&graph, &root);
3952        let model = match build_refs_model(
3953            &index,
3954            "Combo",
3955            true,
3956            DEFAULT_CAP,
3957            INBOUND_CAP,
3958            OUTBOUND_CAP,
3959            false,
3960        ) {
3961            RefsResult::Resolved(m) => m,
3962            other => panic!("expected Resolved, got {other:?}"),
3963        };
3964        assert_eq!(
3965            model
3966                .outbound
3967                .as_ref()
3968                .expect("built with out=true")
3969                .uses_type
3970                .total,
3971            1,
3972            "the single outbound uses-type edge must not be counted twice"
3973        );
3974        assert_eq!(
3975            model
3976                .outbound
3977                .as_ref()
3978                .expect("built with out=true")
3979                .imports
3980                .total,
3981            1,
3982            "the single outbound imports edge must not be counted twice"
3983        );
3984    }
3985
3986    // --- 6: ambiguous edges land in a separate trailing section ---
3987
3988    #[test]
3989    fn build_refs_model_ambiguous_edges_never_guessed_into_inbound() {
3990        let graph = base_fixture_graph();
3991        let root = base_fixture_root();
3992        let index = load_graph_index(&graph, &root);
3993        let model = match build_refs_model(
3994            &index,
3995            "App.One.Config",
3996            true,
3997            DEFAULT_CAP,
3998            INBOUND_CAP,
3999            OUTBOUND_CAP,
4000            false,
4001        ) {
4002            RefsResult::Resolved(m) => m,
4003            other => panic!("expected Resolved, got {other:?}"),
4004        };
4005
4006        assert_eq!(model.inbound.inherits.total, 0);
4007        assert_eq!(model.inbound.uses_type.total, 0, "the ambiguous ref to \"Config\" must not be counted as a resolved inbound uses-type hit");
4008        assert_eq!(model.ambiguous.inbound.total, 1);
4009        assert_eq!(model.ambiguous.inbound.rows[0].raw, "Config");
4010        assert_eq!(model.ambiguous.inbound.rows[0].candidate_count, 2);
4011    }
4012
4013    // --- 7: an ambiguous QUERY name returns candidates, never a guess ---
4014
4015    #[test]
4016    fn build_refs_model_ambiguous_query_name_returns_candidates() {
4017        let graph = base_fixture_graph();
4018        let root = base_fixture_root();
4019        let index = load_graph_index(&graph, &root);
4020        match build_refs_model(
4021            &index,
4022            "Config",
4023            true,
4024            DEFAULT_CAP,
4025            INBOUND_CAP,
4026            OUTBOUND_CAP,
4027            false,
4028        ) {
4029            RefsResult::Ambiguous(mut ids) => {
4030                ids.sort();
4031                assert_eq!(
4032                    ids,
4033                    vec!["App.One.Config".to_string(), "App.Two.Config".to_string()]
4034                );
4035            }
4036            other => panic!("expected Ambiguous, got {other:?}"),
4037        }
4038    }
4039
4040    // --- 8: caps a table, always reports dropped ---
4041
4042    #[test]
4043    fn build_refs_model_caps_a_table_and_reports_dropped() {
4044        let edges: Vec<graph::Edge> = (0..5)
4045            .map(|i| {
4046                uses_type(
4047                    &format!("Consumers/C{i}.cs"),
4048                    1,
4049                    "App.Hot.Popular",
4050                    "Hot/Popular.cs",
4051                )
4052            })
4053            .collect();
4054        let graph = make_graph(
4055            vec![def(
4056                "App.Hot.Popular",
4057                "Popular",
4058                "App.Hot",
4059                "class",
4060                "Hot/Popular.cs",
4061                1,
4062            )],
4063            edges,
4064        );
4065        let root = temp_repo_root("cap-table");
4066        let mut files: Vec<String> = (0..5).map(|i| format!("Consumers/C{i}.cs")).collect();
4067        files.push("Hot/Popular.cs".to_string());
4068        let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
4069        write_manifest_fixture(&root, &file_refs);
4070        let index = load_graph_index(&graph, &root);
4071        let model = match build_refs_model(&index, "Popular", true, 2, 2, OUTBOUND_CAP, false) {
4072            RefsResult::Resolved(m) => m,
4073            other => panic!("expected Resolved, got {other:?}"),
4074        };
4075        assert_eq!(model.inbound.uses_type.total, 5);
4076        assert_eq!(
4077            model.inbound.uses_type.rows.len(),
4078            2,
4079            "must respect the cap"
4080        );
4081        assert_eq!(
4082            model.inbound.uses_type.dropped, 3,
4083            "must always report how many rows were dropped"
4084        );
4085    }
4086
4087    // --- 8b: the outbound tables exist only when asked for ---
4088
4089    #[test]
4090    fn build_refs_model_outbound_tables_exist_only_when_asked_for() {
4091        let graph = base_fixture_graph();
4092        let root = base_fixture_root();
4093        let index = load_graph_index(&graph, &root);
4094        let resolved = |out: bool| match build_refs_model(
4095            &index,
4096            "WidgetImpl",
4097            out,
4098            DEFAULT_CAP,
4099            INBOUND_CAP,
4100            OUTBOUND_CAP,
4101            false,
4102        ) {
4103            RefsResult::Resolved(m) => m,
4104            other => panic!("expected Resolved, got {other:?}"),
4105        };
4106        assert!(
4107            resolved(false).outbound.is_none(),
4108            "the default model has no outbound tables at all"
4109        );
4110        assert!(resolved(true).outbound.is_some(), "--out brings them back");
4111    }
4112
4113    // --- 8c: one cap over the three inbound kinds, ranked ---
4114
4115    #[test]
4116    fn build_refs_model_one_inbound_cap_ranked_resolved_then_same_project() {
4117        // 3 uses-member (2 of them guesses) + 2 uses-type, cap 3. A per-kind
4118        // cap would show all three uses-member rows; the shared one spends the
4119        // budget on the facts first, and among facts on the def's own project.
4120        let graph = make_graph(
4121            vec![def(
4122                "App.Hot.Popular",
4123                "Popular",
4124                "App.Hot",
4125                "class",
4126                "Hot/Popular.cs",
4127                1,
4128            )],
4129            vec![
4130                uses_member("Hot/Near.cs", 3, "App.Hot.Popular", "Hot/Popular.cs"),
4131                heuristic_uses_member("Cold/Guess.cs", 4, "App.Hot.Popular", "Hot/Popular.cs"),
4132                heuristic_uses_member("Hot/Guess.cs", 5, "App.Hot.Popular", "Hot/Popular.cs"),
4133                uses_type("Cold/Far.cs", 6, "App.Hot.Popular", "Hot/Popular.cs"),
4134                uses_type("Hot/AlsoNear.cs", 7, "App.Hot.Popular", "Hot/Popular.cs"),
4135            ],
4136        );
4137        let root = temp_repo_root("inbound-rank");
4138        write_manifest_fixture(
4139            &root,
4140            &[
4141                "Hot/Popular.cs",
4142                "Hot/Near.cs",
4143                "Hot/Guess.cs",
4144                "Hot/AlsoNear.cs",
4145                "Cold/Guess.cs",
4146                "Cold/Far.cs",
4147            ],
4148        );
4149        let index = load_graph_index(&graph, &root);
4150        let model = match build_refs_model(
4151            &index,
4152            "Popular",
4153            false,
4154            DEFAULT_CAP,
4155            3,
4156            OUTBOUND_CAP,
4157            false,
4158        ) {
4159            RefsResult::Resolved(m) => m,
4160            other => panic!("expected Resolved, got {other:?}"),
4161        };
4162
4163        fn files(t: &Table<InboundRow>) -> Vec<&str> {
4164            t.rows.iter().map(|r| r.file.as_str()).collect()
4165        }
4166        assert_eq!(
4167            files(&model.inbound.uses_type),
4168            vec!["Hot/AlsoNear.cs", "Cold/Far.cs"]
4169        );
4170        assert_eq!(files(&model.inbound.uses_member), vec!["Hot/Near.cs"]);
4171        assert_eq!(model.inbound.uses_member.total, 3);
4172        assert_eq!(
4173            model.inbound.uses_member.dropped, 2,
4174            "both guesses lost the budget to the facts"
4175        );
4176        assert_eq!(model.inbound.uses_type.dropped, 0);
4177    }
4178
4179    // --- 8d: one trimmed source line per shown hit ---
4180
4181    #[test]
4182    fn build_refs_model_shown_hit_carries_its_trimmed_source_line_and_a_missing_file_carries_none()
4183    {
4184        let graph = make_graph(
4185            vec![def(
4186                "App.Hot.Popular",
4187                "Popular",
4188                "App.Hot",
4189                "class",
4190                "Hot/Popular.cs",
4191                1,
4192            )],
4193            vec![
4194                uses_type("Hot/Real.cs", 2, "App.Hot.Popular", "Hot/Popular.cs"),
4195                uses_type("Hot/Absent.cs", 2, "App.Hot.Popular", "Hot/Popular.cs"),
4196            ],
4197        );
4198        let root = temp_repo_root("source-line");
4199        write_manifest_fixture(&root, &["Hot/Popular.cs", "Hot/Real.cs", "Hot/Absent.cs"]);
4200        std::fs::create_dir_all(root.join("Hot")).expect("fixture dir");
4201        std::fs::write(
4202            root.join("Hot/Real.cs"),
4203            "class Real\n\t{\tpublic Popular P { get; set; }\t}\n",
4204        )
4205        .expect("fixture file");
4206
4207        let index = load_graph_index(&graph, &root);
4208        let model = match build_refs_model(
4209            &index,
4210            "Popular",
4211            false,
4212            DEFAULT_CAP,
4213            INBOUND_CAP,
4214            OUTBOUND_CAP,
4215            false,
4216        ) {
4217            RefsResult::Resolved(m) => m,
4218            other => panic!("expected Resolved, got {other:?}"),
4219        };
4220        let row = |file: &str| {
4221            model
4222                .inbound
4223                .uses_type
4224                .rows
4225                .iter()
4226                .find(|r| r.file == file)
4227                .expect("row")
4228                .clone()
4229        };
4230        assert_eq!(
4231            row("Hot/Real.cs").source,
4232            "{ public Popular P { get; set; } }",
4233            "tabs collapse to single spaces, the indent is trimmed off"
4234        );
4235        assert_eq!(
4236            row("Hot/Absent.cs").source,
4237            "",
4238            "a file that is not on disk yields no line, never a partial one"
4239        );
4240    }
4241
4242    // --- --out mirrors of 8c/8d above, over the four outbound kinds ---
4243
4244    #[test]
4245    fn build_refs_model_one_outbound_cap_ranked_resolved_then_same_project_imports_foreign() {
4246        // A same-project resolved uses-type, an always-foreign imports edge, a
4247        // foreign resolved inherits edge and a same-project heuristic
4248        // uses-member, cap 2. Imports is never a guess so it beats both the
4249        // foreign inherits row and the heuristic row; among the two foreign,
4250        // non-heuristic rows (imports line 1, inherits line 6) the file/line
4251        // tiebreak decides, since project ties them.
4252        let graph = make_graph(
4253            vec![
4254                def(
4255                    "App.Hot.Consumer",
4256                    "Consumer",
4257                    "App.Hot",
4258                    "class",
4259                    "Hot/Consumer.cs",
4260                    1,
4261                ),
4262                def(
4263                    "App.Hot.Local",
4264                    "Local",
4265                    "App.Hot",
4266                    "class",
4267                    "Hot/Local.cs",
4268                    1,
4269                ),
4270                def("App.Cold.Far", "Far", "App.Cold", "class", "Cold/Far.cs", 1),
4271            ],
4272            vec![
4273                uses_type("Hot/Consumer.cs", 5, "App.Hot.Local", "Hot/Local.cs"),
4274                imports("Hot/Consumer.cs", 1, "System"),
4275                inherits("Hot/Consumer.cs", 6, "App.Cold.Far", "Cold/Far.cs"),
4276                heuristic_uses_member("Hot/Consumer.cs", 7, "App.Hot.Local", "Hot/Local.cs"),
4277            ],
4278        );
4279        let root = temp_repo_root("outbound-rank");
4280        write_manifest_fixture(&root, &["Hot/Consumer.cs", "Hot/Local.cs", "Cold/Far.cs"]);
4281        let index = load_graph_index(&graph, &root);
4282        let model =
4283            match build_refs_model(&index, "Consumer", true, DEFAULT_CAP, INBOUND_CAP, 2, false) {
4284                RefsResult::Resolved(m) => m,
4285                other => panic!("expected Resolved, got {other:?}"),
4286            };
4287        let ob = model.outbound.as_ref().expect("built with out=true");
4288
4289        assert_eq!(
4290            ob.uses_type.rows.len(),
4291            1,
4292            "the same-project resolved edge spends the shared budget first"
4293        );
4294        assert_eq!(ob.uses_type.rows[0].line, 5);
4295        assert_eq!(
4296            ob.imports.rows.len(),
4297            1,
4298            "imports is never a guess, so it beats both the foreign inherits and the heuristic row"
4299        );
4300        assert_eq!(ob.imports.rows[0].line, 1);
4301        assert_eq!(
4302            ob.inherits.rows.len(),
4303            0,
4304            "the foreign inherits edge lost the shared budget"
4305        );
4306        assert_eq!(ob.inherits.dropped, 1);
4307        assert_eq!(
4308            ob.uses_member.rows.len(),
4309            0,
4310            "the heuristic guess lost the budget too, project notwithstanding"
4311        );
4312        assert_eq!(ob.uses_member.dropped, 1);
4313        assert_eq!(ob.imports.dropped, 0);
4314        assert_eq!(ob.uses_type.dropped, 0);
4315    }
4316
4317    #[test]
4318    fn build_refs_model_shown_outbound_hit_carries_its_trimmed_source_line_and_a_missing_file_carries_none(
4319    ) {
4320        let graph = make_graph(
4321            vec![
4322                def_also(
4323                    "App.Hot.Consumer",
4324                    "Consumer",
4325                    "App.Hot",
4326                    "class",
4327                    "Hot/Consumer.cs",
4328                    1,
4329                    vec![("Hot/Consumer.Extra.cs", 1)],
4330                ),
4331                def(
4332                    "App.Hot.Local",
4333                    "Local",
4334                    "App.Hot",
4335                    "class",
4336                    "Hot/Local.cs",
4337                    1,
4338                ),
4339            ],
4340            vec![
4341                uses_type("Hot/Consumer.cs", 2, "App.Hot.Local", "Hot/Local.cs"),
4342                uses_type("Hot/Consumer.Extra.cs", 3, "App.Hot.Local", "Hot/Local.cs"),
4343            ],
4344        );
4345        let root = temp_repo_root("outbound-source-line");
4346        write_manifest_fixture(
4347            &root,
4348            &["Hot/Consumer.cs", "Hot/Consumer.Extra.cs", "Hot/Local.cs"],
4349        );
4350        std::fs::create_dir_all(root.join("Hot")).expect("fixture dir");
4351        std::fs::write(
4352            root.join("Hot/Consumer.cs"),
4353            "x\n\t{\tpublic Local L { get; set; }\t}\n",
4354        )
4355        .expect("fixture file");
4356        // Hot/Consumer.Extra.cs is deliberately never written to disk.
4357
4358        let index = load_graph_index(&graph, &root);
4359        let model = match build_refs_model(
4360            &index,
4361            "Consumer",
4362            true,
4363            DEFAULT_CAP,
4364            INBOUND_CAP,
4365            OUTBOUND_CAP,
4366            false,
4367        ) {
4368            RefsResult::Resolved(m) => m,
4369            other => panic!("expected Resolved, got {other:?}"),
4370        };
4371        let rows = &model
4372            .outbound
4373            .as_ref()
4374            .expect("built with out=true")
4375            .uses_type
4376            .rows;
4377        let row = |line: usize| rows.iter().find(|r| r.line == line).expect("row").clone();
4378        assert_eq!(
4379            row(2).source,
4380            "{ public Local L { get; set; } }",
4381            "tabs collapse to single spaces, the indent is trimmed off"
4382        );
4383        assert_eq!(
4384            row(3).source,
4385            "",
4386            "a file that is not on disk yields no line, never a partial one"
4387        );
4388    }
4389
4390    #[test]
4391    fn build_refs_model_all_out_lifts_the_outbound_cap() {
4392        let edges: Vec<graph::Edge> = (0..5)
4393            .map(|i| imports("Hot/Consumer.cs", i + 1, &format!("Ns{i}")))
4394            .collect();
4395        let graph = make_graph(
4396            vec![def(
4397                "App.Hot.Consumer",
4398                "Consumer",
4399                "App.Hot",
4400                "class",
4401                "Hot/Consumer.cs",
4402                1,
4403            )],
4404            edges,
4405        );
4406        let root = temp_repo_root("outbound-all");
4407        write_manifest_fixture(&root, &["Hot/Consumer.cs"]);
4408        let index = load_graph_index(&graph, &root);
4409
4410        let capped =
4411            match build_refs_model(&index, "Consumer", true, DEFAULT_CAP, INBOUND_CAP, 2, false) {
4412                RefsResult::Resolved(m) => m,
4413                other => panic!("expected Resolved, got {other:?}"),
4414            };
4415        let capped_ob = capped.outbound.as_ref().expect("built with out=true");
4416        assert_eq!(
4417            capped_ob.imports.rows.len(),
4418            2,
4419            "must respect the outbound cap"
4420        );
4421        assert_eq!(capped_ob.imports.dropped, 3);
4422
4423        let all =
4424            match build_refs_model(&index, "Consumer", true, DEFAULT_CAP, INBOUND_CAP, 2, true) {
4425                RefsResult::Resolved(m) => m,
4426                other => panic!("expected Resolved, got {other:?}"),
4427            };
4428        let all_ob = all.outbound.as_ref().expect("built with out=true");
4429        assert_eq!(
4430            all_ob.imports.rows.len(),
4431            5,
4432            "--all lifts the outbound cap entirely"
4433        );
4434        assert_eq!(all_ob.imports.dropped, 0);
4435    }
4436
4437    // `--all` (`all_out`) must lift the INBOUND cap, not just the outbound one:
4438    // without it a caller reaching for `--all` on a truncated inbound table got
4439    // nothing back for it. Five distinct referring files against a cap of 2
4440    // proves the file-loss shape, not just a row-count-under-cap shape.
4441    #[test]
4442    fn build_refs_model_all_out_now_lifts_the_inbound_cap_too() {
4443        let files = [
4444            "Cold/ConsumerA.cs",
4445            "Cold/ConsumerB.cs",
4446            "Cold/ConsumerC.cs",
4447            "Cold/ConsumerD.cs",
4448            "Cold/ConsumerE.cs",
4449        ];
4450        let edges: Vec<graph::Edge> = files
4451            .iter()
4452            .map(|f| uses_type(f, 1, "App.Hot.Widget", "Hot/Widget.cs"))
4453            .collect();
4454        let graph = make_graph(
4455            vec![def(
4456                "App.Hot.Widget",
4457                "Widget",
4458                "App.Hot",
4459                "class",
4460                "Hot/Widget.cs",
4461                1,
4462            )],
4463            edges,
4464        );
4465        let root = temp_repo_root("inbound-all");
4466        let mut manifest_files: Vec<&str> = vec!["Hot/Widget.cs"];
4467        manifest_files.extend_from_slice(&files);
4468        write_manifest_fixture(&root, &manifest_files);
4469        let index = load_graph_index(&graph, &root);
4470
4471        let capped =
4472            match build_refs_model(&index, "Widget", false, DEFAULT_CAP, 2, OUTBOUND_CAP, false) {
4473                RefsResult::Resolved(m) => m,
4474                other => panic!("expected Resolved, got {other:?}"),
4475            };
4476        assert_eq!(
4477            capped.inbound.uses_type.rows.len(),
4478            2,
4479            "must respect the inbound cap"
4480        );
4481        assert_eq!(
4482            capped.inbound.uses_type.dropped, 3,
4483            "the other 3 referring files are lost without --all"
4484        );
4485
4486        let all =
4487            match build_refs_model(&index, "Widget", false, DEFAULT_CAP, 2, OUTBOUND_CAP, true) {
4488                RefsResult::Resolved(m) => m,
4489                other => panic!("expected Resolved, got {other:?}"),
4490            };
4491        assert_eq!(
4492            all.inbound.uses_type.rows.len(),
4493            5,
4494            "--all lifts the inbound cap too, not just the outbound one"
4495        );
4496        assert_eq!(all.inbound.uses_type.dropped, 0);
4497    }
4498
4499    // --- 9: build_impact_model hop limit ---
4500
4501    #[test]
4502    fn build_impact_model_hop_limit_honored() {
4503        let graph = base_fixture_graph();
4504        let root = base_fixture_root();
4505        let index = load_graph_index(&graph, &root);
4506
4507        let one_hop = match build_impact_model(
4508            &index,
4509            "IWidget",
4510            1,
4511            DEFAULT_CAP,
4512            true,
4513            DEFAULT_IFACE_MAX_FANIN,
4514            DEFAULT_HUB_MAX_INDEGREE,
4515        ) {
4516            ImpactResult::Resolved(m) => m,
4517            other => panic!("expected Resolved, got {other:?}"),
4518        };
4519        let mut files: Vec<&str> = one_hop.rows.iter().map(|r| r.file.as_str()).collect();
4520        files.sort();
4521        assert_eq!(
4522            files,
4523            vec![
4524                "Consumers/Holder.cs",
4525                "Widgets/Impl/OtherImpl.cs",
4526                "Widgets/Impl/WidgetImpl.cs"
4527            ]
4528        );
4529        assert!(
4530            !one_hop.rows.iter().any(|r| r.file == "Consumers/TwoHop.cs"),
4531            "TwoHop.cs is 2 hops away and must not appear at hops=1"
4532        );
4533
4534        let two_hop = match build_impact_model(
4535            &index,
4536            "IWidget",
4537            2,
4538            DEFAULT_CAP,
4539            true,
4540            DEFAULT_IFACE_MAX_FANIN,
4541            DEFAULT_HUB_MAX_INDEGREE,
4542        ) {
4543            ImpactResult::Resolved(m) => m,
4544            other => panic!("expected Resolved, got {other:?}"),
4545        };
4546        let two_hop_row = two_hop
4547            .rows
4548            .iter()
4549            .find(|r| r.file == "Consumers/TwoHop.cs")
4550            .expect("TwoHop.cs must appear at hops=2");
4551        assert_eq!(two_hop_row.hop, 2);
4552        assert_eq!(
4553            two_hop_row.top_symbols,
4554            vec!["WidgetImpl".to_string()],
4555            "reached via WidgetImpl, not IWidget directly"
4556        );
4557    }
4558
4559    // --- 10: file-path seed ---
4560
4561    #[test]
4562    fn build_impact_model_accepts_a_file_path_seeding_every_def_in_it() {
4563        let graph = base_fixture_graph();
4564        let root = base_fixture_root();
4565        let index = load_graph_index(&graph, &root);
4566        let model = match build_impact_model(
4567            &index,
4568            "Widgets/IWidget.cs",
4569            1,
4570            DEFAULT_CAP,
4571            true,
4572            DEFAULT_IFACE_MAX_FANIN,
4573            DEFAULT_HUB_MAX_INDEGREE,
4574        ) {
4575            ImpactResult::Resolved(m) => m,
4576            other => panic!("expected Resolved, got {other:?}"),
4577        };
4578        assert_eq!(model.kind, SeedKind::File);
4579        assert_eq!(model.seed_files, vec!["Widgets/IWidget.cs".to_string()]);
4580        let mut files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
4581        files.sort();
4582        assert_eq!(
4583            files,
4584            vec![
4585                "Consumers/Holder.cs",
4586                "Widgets/Impl/OtherImpl.cs",
4587                "Widgets/Impl/WidgetImpl.cs"
4588            ]
4589        );
4590    }
4591
4592    // --- 11: unknown file path -> notfound, not treated as a symbol ---
4593
4594    #[test]
4595    fn build_impact_model_unknown_file_path_is_reported_not_silently_a_symbol() {
4596        let graph = base_fixture_graph();
4597        let root = base_fixture_root();
4598        let index = load_graph_index(&graph, &root);
4599        match build_impact_model(
4600            &index,
4601            "Nowhere/Missing.cs",
4602            1,
4603            DEFAULT_CAP,
4604            true,
4605            DEFAULT_IFACE_MAX_FANIN,
4606            DEFAULT_HUB_MAX_INDEGREE,
4607        ) {
4608            ImpactResult::NotFound { kind } => assert_eq!(kind, SeedKind::File),
4609            other => panic!("expected NotFound, got {other:?}"),
4610        }
4611    }
4612
4613    // --- 12: ranking: 1-hop outranks 2-hop; nothing dropped below cap ---
4614
4615    #[test]
4616    fn build_impact_model_ranking_orders_direct_ahead_of_indirect_never_drops_below_cap() {
4617        let graph = base_fixture_graph();
4618        let root = base_fixture_root();
4619        let index = load_graph_index(&graph, &root);
4620        let model = match build_impact_model(
4621            &index,
4622            "IWidget",
4623            2,
4624            DEFAULT_CAP,
4625            true,
4626            DEFAULT_IFACE_MAX_FANIN,
4627            DEFAULT_HUB_MAX_INDEGREE,
4628        ) {
4629            ImpactResult::Resolved(m) => m,
4630            other => panic!("expected Resolved, got {other:?}"),
4631        };
4632        let score_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap().score;
4633        let hop_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap().hop;
4634        assert!(
4635            score_of("Widgets/Impl/WidgetImpl.cs") > score_of("Consumers/TwoHop.cs"),
4636            "1-hop dependent must outrank the 2-hop one"
4637        );
4638        assert_eq!(hop_of("Consumers/TwoHop.cs"), 2);
4639        assert_eq!(model.dropped, 0);
4640        assert_eq!(
4641            model.total_affected,
4642            model.rows.len(),
4643            "nothing filtered beyond the (unhit) cap"
4644        );
4645    }
4646
4647    // --- 13: ranking determinism across repeated runs ---
4648
4649    #[test]
4650    fn build_impact_model_ranking_deterministic_across_repeated_runs() {
4651        let graph = base_fixture_graph();
4652        let root = base_fixture_root();
4653        let index = load_graph_index(&graph, &root);
4654        let a: Vec<String> = match build_impact_model(
4655            &index,
4656            "IWidget",
4657            2,
4658            DEFAULT_CAP,
4659            true,
4660            DEFAULT_IFACE_MAX_FANIN,
4661            DEFAULT_HUB_MAX_INDEGREE,
4662        ) {
4663            ImpactResult::Resolved(m) => m.rows.into_iter().map(|r| r.file).collect(),
4664            other => panic!("expected Resolved, got {other:?}"),
4665        };
4666        let b: Vec<String> = match build_impact_model(
4667            &index,
4668            "IWidget",
4669            2,
4670            DEFAULT_CAP,
4671            true,
4672            DEFAULT_IFACE_MAX_FANIN,
4673            DEFAULT_HUB_MAX_INDEGREE,
4674        ) {
4675            ImpactResult::Resolved(m) => m.rows.into_iter().map(|r| r.file).collect(),
4676            other => panic!("expected Resolved, got {other:?}"),
4677        };
4678        assert_eq!(a, b);
4679    }
4680
4681    // --- the interface hop ---
4682
4683    fn ctor_di_to(
4684        from_file: &str,
4685        from_line: usize,
4686        iface: &str,
4687        resolution: &str,
4688        to: &str,
4689    ) -> graph::Edge {
4690        graph::Edge::CtorDi {
4691            from_file: from_file.into(),
4692            from_line,
4693            iface: iface.into(),
4694            resolution: resolution.into(),
4695            args: None,
4696            to: Some(to.into()),
4697            candidates: vec![],
4698        }
4699    }
4700    fn ctor_di_no_to(
4701        from_file: &str,
4702        from_line: usize,
4703        iface: &str,
4704        resolution: &str,
4705    ) -> graph::Edge {
4706        graph::Edge::CtorDi {
4707            from_file: from_file.into(),
4708            from_line,
4709            iface: iface.into(),
4710            resolution: resolution.into(),
4711            args: None,
4712            to: None,
4713            candidates: vec![],
4714        }
4715    }
4716
4717    const IFACE_HOP_MANIFEST_FILES: &[&str] = &[
4718        "Pay/IPaymentGateway.cs",
4719        "Pay/StripeGateway.cs",
4720        "Pay/OrderService.cs",
4721        "Pay/RefundService.cs",
4722        "Pay/GatewayFactory.cs",
4723        "Pay/GatewayHolder.cs",
4724        "Pay/AuditLogger.cs",
4725    ];
4726
4727    /// `StripeGateway` is `IPaymentGateway`'s SOLE implementor. `OrderService`/
4728    /// `RefundService` ctor-inject the interface (never naming the class) --
4729    /// each also carries the COMPANION plain `uses-type` ref always emitted
4730    /// alongside a ctor-param ref, at the identical from_file/from_line,
4731    /// resolving to the INTERFACE (dedup-proving). `GatewayFactory` names
4732    /// `StripeGateway` directly (a plain direct-name hit). `GatewayHolder`
4733    /// references `IPaymentGateway` directly but NOT through a constructor (a
4734    /// property type, distinct file/line from every ctor-di site). `AuditLogger`
4735    /// carries an unrelated, unresolvable `ILogger` ctor-di edge (`infra`, no
4736    /// `to`) that must never widen anything.
4737    fn iface_hop_fixture_graph() -> graph::Graph {
4738        make_graph(
4739            vec![
4740                def(
4741                    "App.Pay.IPaymentGateway",
4742                    "IPaymentGateway",
4743                    "App.Pay",
4744                    "interface",
4745                    "Pay/IPaymentGateway.cs",
4746                    3,
4747                ),
4748                def(
4749                    "App.Pay.StripeGateway",
4750                    "StripeGateway",
4751                    "App.Pay",
4752                    "class",
4753                    "Pay/StripeGateway.cs",
4754                    3,
4755                ),
4756                def(
4757                    "App.Pay.OrderService",
4758                    "OrderService",
4759                    "App.Pay",
4760                    "class",
4761                    "Pay/OrderService.cs",
4762                    3,
4763                ),
4764                def(
4765                    "App.Pay.RefundService",
4766                    "RefundService",
4767                    "App.Pay",
4768                    "class",
4769                    "Pay/RefundService.cs",
4770                    3,
4771                ),
4772                def(
4773                    "App.Pay.GatewayFactory",
4774                    "GatewayFactory",
4775                    "App.Pay",
4776                    "class",
4777                    "Pay/GatewayFactory.cs",
4778                    3,
4779                ),
4780                def(
4781                    "App.Pay.GatewayHolder",
4782                    "GatewayHolder",
4783                    "App.Pay",
4784                    "class",
4785                    "Pay/GatewayHolder.cs",
4786                    3,
4787                ),
4788                def(
4789                    "App.Pay.AuditLogger",
4790                    "AuditLogger",
4791                    "App.Pay",
4792                    "class",
4793                    "Pay/AuditLogger.cs",
4794                    3,
4795                ),
4796            ],
4797            vec![
4798                inherits(
4799                    "Pay/StripeGateway.cs",
4800                    3,
4801                    "App.Pay.IPaymentGateway",
4802                    "Pay/IPaymentGateway.cs",
4803                ),
4804                ctor_di_to(
4805                    "Pay/OrderService.cs",
4806                    5,
4807                    "IPaymentGateway",
4808                    "plain",
4809                    "App.Pay.StripeGateway",
4810                ),
4811                uses_type(
4812                    "Pay/OrderService.cs",
4813                    5,
4814                    "App.Pay.IPaymentGateway",
4815                    "Pay/IPaymentGateway.cs",
4816                ),
4817                ctor_di_to(
4818                    "Pay/RefundService.cs",
4819                    5,
4820                    "IPaymentGateway",
4821                    "plain",
4822                    "App.Pay.StripeGateway",
4823                ),
4824                uses_type(
4825                    "Pay/RefundService.cs",
4826                    5,
4827                    "App.Pay.IPaymentGateway",
4828                    "Pay/IPaymentGateway.cs",
4829                ),
4830                uses_type(
4831                    "Pay/GatewayFactory.cs",
4832                    6,
4833                    "App.Pay.StripeGateway",
4834                    "Pay/StripeGateway.cs",
4835                ),
4836                uses_type(
4837                    "Pay/GatewayHolder.cs",
4838                    4,
4839                    "App.Pay.IPaymentGateway",
4840                    "Pay/IPaymentGateway.cs",
4841                ),
4842                ctor_di_no_to("Pay/AuditLogger.cs", 5, "ILogger", "infra"),
4843            ],
4844        )
4845    }
4846
4847    fn iface_hop_fixture_root() -> PathBuf {
4848        let root = temp_repo_root("iface-hop");
4849        write_manifest_fixture(&root, IFACE_HOP_MANIFEST_FILES);
4850        root
4851    }
4852
4853    #[test]
4854    fn build_impact_model_widens_through_ctor_injected_interface_consumers_and_direct_interface_references(
4855    ) {
4856        let graph = iface_hop_fixture_graph();
4857        let root = iface_hop_fixture_root();
4858        let index = load_graph_index(&graph, &root);
4859        let model = match build_impact_model(
4860            &index,
4861            "StripeGateway",
4862            1,
4863            DEFAULT_CAP,
4864            true,
4865            DEFAULT_IFACE_MAX_FANIN,
4866            DEFAULT_HUB_MAX_INDEGREE,
4867        ) {
4868            ImpactResult::Resolved(m) => m,
4869            other => panic!("expected Resolved, got {other:?}"),
4870        };
4871        let files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
4872        assert!(
4873            files.contains(&"Pay/GatewayFactory.cs"),
4874            "direct-name reference must still be reached: {files:?}"
4875        );
4876        assert!(
4877            files.contains(&"Pay/OrderService.cs"),
4878            "ctor-injected consumer must be reached: {files:?}"
4879        );
4880        assert!(
4881            files.contains(&"Pay/RefundService.cs"),
4882            "ctor-injected consumer must be reached: {files:?}"
4883        );
4884        assert!(
4885            files.contains(&"Pay/GatewayHolder.cs"),
4886            "direct interface-name reference must be reached: {files:?}"
4887        );
4888        assert!(
4889            !files.contains(&"Pay/AuditLogger.cs"),
4890            "an unrelated infra ctor-di edge must never widen: {files:?}"
4891        );
4892
4893        let row_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap();
4894        assert_eq!(
4895            row_of("Pay/OrderService.cs").hop,
4896            1,
4897            "the interface hop counts as ONE hop, same as a direct reference"
4898        );
4899        assert_eq!(
4900            row_of("Pay/OrderService.cs").iface_via,
4901            vec!["IPaymentGateway (ctor-di)".to_string()]
4902        );
4903        assert_eq!(
4904            row_of("Pay/RefundService.cs").iface_via,
4905            vec!["IPaymentGateway (ctor-di)".to_string()]
4906        );
4907        assert_eq!(
4908            row_of("Pay/GatewayHolder.cs").iface_via,
4909            vec!["IPaymentGateway".to_string()]
4910        );
4911        assert!(
4912            row_of("Pay/GatewayFactory.cs").iface_via.is_empty(),
4913            "a plain direct-name hit carries no iface_via label"
4914        );
4915        // The companion plain `uses-type` ref (same from_file/from_line as the
4916        // ctor-di edge) must be deduped away, not double-counted.
4917        assert_eq!(
4918            row_of("Pay/OrderService.cs").via_count,
4919            1,
4920            "the ctor-di hit and its companion ref are ONE hit, not two"
4921        );
4922    }
4923
4924    #[test]
4925    fn build_impact_model_no_iface_restores_the_pre_ds_0050_radius_byte_for_byte() {
4926        let graph = iface_hop_fixture_graph();
4927        let root = iface_hop_fixture_root();
4928        let index = load_graph_index(&graph, &root);
4929        let model = match build_impact_model(
4930            &index,
4931            "StripeGateway",
4932            1,
4933            DEFAULT_CAP,
4934            false,
4935            DEFAULT_IFACE_MAX_FANIN,
4936            DEFAULT_HUB_MAX_INDEGREE,
4937        ) {
4938            ImpactResult::Resolved(m) => m,
4939            other => panic!("expected Resolved, got {other:?}"),
4940        };
4941        let files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
4942        assert_eq!(
4943            files,
4944            vec!["Pay/GatewayFactory.cs"],
4945            "only the direct-name reference survives with --no-iface"
4946        );
4947        assert!(
4948            model.rows.iter().all(|r| r.iface_via.is_empty()),
4949            "no row may carry an iface_via label with --no-iface"
4950        );
4951    }
4952
4953    /// A second implementor with the SAME bare interface name in a different
4954    /// namespace must never contribute its consumers to the first's radius --
4955    /// the widen is def-id-matched (via the resolver's own confirmed `to`),
4956    /// never name-matched. Also covers the truly ambiguous ctor-di shape
4957    /// (two tied implementors, `to: None`): it must widen neither.
4958    #[test]
4959    fn build_impact_model_never_widens_through_a_same_named_interface_elsewhere_or_an_ambiguous_ctor_di_edge(
4960    ) {
4961        let graph = make_graph(
4962            vec![
4963                def(
4964                    "App.Pay.IPaymentGateway",
4965                    "IPaymentGateway",
4966                    "App.Pay",
4967                    "interface",
4968                    "Pay/IPaymentGateway.cs",
4969                    3,
4970                ),
4971                def(
4972                    "App.Pay.StripeGateway",
4973                    "StripeGateway",
4974                    "App.Pay",
4975                    "class",
4976                    "Pay/StripeGateway.cs",
4977                    3,
4978                ),
4979                def(
4980                    "Other.Billing.IPaymentGateway",
4981                    "IPaymentGateway",
4982                    "Other.Billing",
4983                    "interface",
4984                    "Billing/IPaymentGateway.cs",
4985                    3,
4986                ),
4987                def(
4988                    "Other.Billing.LegacyGateway",
4989                    "LegacyGateway",
4990                    "Other.Billing",
4991                    "class",
4992                    "Billing/LegacyGateway.cs",
4993                    3,
4994                ),
4995                def(
4996                    "App.Pay.UnrelatedConsumer",
4997                    "UnrelatedConsumer",
4998                    "App.Pay",
4999                    "class",
5000                    "Pay/UnrelatedConsumer.cs",
5001                    3,
5002                ),
5003            ],
5004            vec![
5005                inherits(
5006                    "Pay/StripeGateway.cs",
5007                    3,
5008                    "App.Pay.IPaymentGateway",
5009                    "Pay/IPaymentGateway.cs",
5010                ),
5011                inherits(
5012                    "Billing/LegacyGateway.cs",
5013                    3,
5014                    "Other.Billing.IPaymentGateway",
5015                    "Billing/IPaymentGateway.cs",
5016                ),
5017                // Names the OTHER namespace's IPaymentGateway -- must never
5018                // reach StripeGateway just because the bare name matches.
5019                ctor_di_to(
5020                    "Pay/UnrelatedConsumer.cs",
5021                    5,
5022                    "IPaymentGateway",
5023                    "plain",
5024                    "Other.Billing.LegacyGateway",
5025                ),
5026                // A tied ('ambiguous') ctor-di edge naming StripeGateway's
5027                // OWN interface -- no `to`, so it must not widen either.
5028                ctor_di_no_to(
5029                    "Pay/UnrelatedConsumer.cs",
5030                    9,
5031                    "IPaymentGateway",
5032                    "ambiguous",
5033                ),
5034            ],
5035        );
5036        let root = temp_repo_root("iface-hop-collision");
5037        write_manifest_fixture(
5038            &root,
5039            &[
5040                "Pay/IPaymentGateway.cs",
5041                "Pay/StripeGateway.cs",
5042                "Billing/IPaymentGateway.cs",
5043                "Billing/LegacyGateway.cs",
5044                "Pay/UnrelatedConsumer.cs",
5045            ],
5046        );
5047        let index = load_graph_index(&graph, &root);
5048        let model = match build_impact_model(
5049            &index,
5050            "StripeGateway",
5051            1,
5052            DEFAULT_CAP,
5053            true,
5054            DEFAULT_IFACE_MAX_FANIN,
5055            DEFAULT_HUB_MAX_INDEGREE,
5056        ) {
5057            ImpactResult::Resolved(m) => m,
5058            other => panic!("expected Resolved, got {other:?}"),
5059        };
5060        assert!(model.rows.is_empty(), "a same-named interface elsewhere, and an ambiguous ctor-di edge, must never widen: {:?}", model.rows);
5061    }
5062
5063    // --- the broad-interface fan-in brake ---
5064
5065    const BROAD_IFACE_MANIFEST_FILES: &[&str] = &[
5066        "Widgets/IWidgetRepository.cs",
5067        "Widgets/IWidgetClock.cs",
5068        "Widgets/WidgetRepository.cs",
5069        "Widgets/GadgetService.cs",
5070        "Widgets/WidgetConsumer00.cs",
5071        "Widgets/WidgetConsumer01.cs",
5072        "Widgets/WidgetConsumer02.cs",
5073        "Widgets/WidgetConsumer03.cs",
5074        "Widgets/WidgetConsumer04.cs",
5075        "Widgets/WidgetConsumer05.cs",
5076        "Widgets/WidgetConsumer06.cs",
5077        "Widgets/WidgetConsumer07.cs",
5078        "Widgets/WidgetConsumer08.cs",
5079        "Widgets/ClockConsumer0.cs",
5080        "Widgets/ClockConsumer1.cs",
5081    ];
5082
5083    /// `WidgetRepository` is the SOLE implementor of two contracts:
5084    /// `IWidgetRepository`, ctor-injected by 9 distinct constructors (one over
5085    /// the default threshold of 8), and `IWidgetClock`, injected by 2. Both are
5086    /// ordinary, well-named application interfaces -- nothing about their names
5087    /// or namespaces marks the first as plumbing, which is exactly why the
5088    /// name-pattern `infra` class never catches this shape. `GadgetService`
5089    /// names the concrete class directly.
5090    fn broad_iface_fixture_graph() -> graph::Graph {
5091        make_graph(
5092            vec![
5093                def(
5094                    "App.Widgets.IWidgetRepository",
5095                    "IWidgetRepository",
5096                    "App.Widgets",
5097                    "interface",
5098                    "Widgets/IWidgetRepository.cs",
5099                    3,
5100                ),
5101                def(
5102                    "App.Widgets.IWidgetClock",
5103                    "IWidgetClock",
5104                    "App.Widgets",
5105                    "interface",
5106                    "Widgets/IWidgetClock.cs",
5107                    3,
5108                ),
5109                def(
5110                    "App.Widgets.WidgetRepository",
5111                    "WidgetRepository",
5112                    "App.Widgets",
5113                    "class",
5114                    "Widgets/WidgetRepository.cs",
5115                    3,
5116                ),
5117                def(
5118                    "App.Widgets.GadgetService",
5119                    "GadgetService",
5120                    "App.Widgets",
5121                    "class",
5122                    "Widgets/GadgetService.cs",
5123                    3,
5124                ),
5125                def(
5126                    "App.Widgets.WidgetConsumer00",
5127                    "WidgetConsumer00",
5128                    "App.Widgets",
5129                    "class",
5130                    "Widgets/WidgetConsumer00.cs",
5131                    3,
5132                ),
5133                def(
5134                    "App.Widgets.WidgetConsumer01",
5135                    "WidgetConsumer01",
5136                    "App.Widgets",
5137                    "class",
5138                    "Widgets/WidgetConsumer01.cs",
5139                    3,
5140                ),
5141                def(
5142                    "App.Widgets.WidgetConsumer02",
5143                    "WidgetConsumer02",
5144                    "App.Widgets",
5145                    "class",
5146                    "Widgets/WidgetConsumer02.cs",
5147                    3,
5148                ),
5149                def(
5150                    "App.Widgets.WidgetConsumer03",
5151                    "WidgetConsumer03",
5152                    "App.Widgets",
5153                    "class",
5154                    "Widgets/WidgetConsumer03.cs",
5155                    3,
5156                ),
5157                def(
5158                    "App.Widgets.WidgetConsumer04",
5159                    "WidgetConsumer04",
5160                    "App.Widgets",
5161                    "class",
5162                    "Widgets/WidgetConsumer04.cs",
5163                    3,
5164                ),
5165                def(
5166                    "App.Widgets.WidgetConsumer05",
5167                    "WidgetConsumer05",
5168                    "App.Widgets",
5169                    "class",
5170                    "Widgets/WidgetConsumer05.cs",
5171                    3,
5172                ),
5173                def(
5174                    "App.Widgets.WidgetConsumer06",
5175                    "WidgetConsumer06",
5176                    "App.Widgets",
5177                    "class",
5178                    "Widgets/WidgetConsumer06.cs",
5179                    3,
5180                ),
5181                def(
5182                    "App.Widgets.WidgetConsumer07",
5183                    "WidgetConsumer07",
5184                    "App.Widgets",
5185                    "class",
5186                    "Widgets/WidgetConsumer07.cs",
5187                    3,
5188                ),
5189                def(
5190                    "App.Widgets.WidgetConsumer08",
5191                    "WidgetConsumer08",
5192                    "App.Widgets",
5193                    "class",
5194                    "Widgets/WidgetConsumer08.cs",
5195                    3,
5196                ),
5197                def(
5198                    "App.Widgets.ClockConsumer0",
5199                    "ClockConsumer0",
5200                    "App.Widgets",
5201                    "class",
5202                    "Widgets/ClockConsumer0.cs",
5203                    3,
5204                ),
5205                def(
5206                    "App.Widgets.ClockConsumer1",
5207                    "ClockConsumer1",
5208                    "App.Widgets",
5209                    "class",
5210                    "Widgets/ClockConsumer1.cs",
5211                    3,
5212                ),
5213            ],
5214            vec![
5215                inherits(
5216                    "Widgets/WidgetRepository.cs",
5217                    3,
5218                    "App.Widgets.IWidgetRepository",
5219                    "Widgets/IWidgetRepository.cs",
5220                ),
5221                inherits(
5222                    "Widgets/WidgetRepository.cs",
5223                    3,
5224                    "App.Widgets.IWidgetClock",
5225                    "Widgets/IWidgetClock.cs",
5226                ),
5227                uses_type(
5228                    "Widgets/GadgetService.cs",
5229                    6,
5230                    "App.Widgets.WidgetRepository",
5231                    "Widgets/WidgetRepository.cs",
5232                ),
5233                ctor_di_to(
5234                    "Widgets/WidgetConsumer00.cs",
5235                    5,
5236                    "IWidgetRepository",
5237                    "plain",
5238                    "App.Widgets.WidgetRepository",
5239                ),
5240                uses_type(
5241                    "Widgets/WidgetConsumer00.cs",
5242                    5,
5243                    "App.Widgets.IWidgetRepository",
5244                    "Widgets/IWidgetRepository.cs",
5245                ),
5246                ctor_di_to(
5247                    "Widgets/WidgetConsumer01.cs",
5248                    5,
5249                    "IWidgetRepository",
5250                    "plain",
5251                    "App.Widgets.WidgetRepository",
5252                ),
5253                uses_type(
5254                    "Widgets/WidgetConsumer01.cs",
5255                    5,
5256                    "App.Widgets.IWidgetRepository",
5257                    "Widgets/IWidgetRepository.cs",
5258                ),
5259                ctor_di_to(
5260                    "Widgets/WidgetConsumer02.cs",
5261                    5,
5262                    "IWidgetRepository",
5263                    "plain",
5264                    "App.Widgets.WidgetRepository",
5265                ),
5266                uses_type(
5267                    "Widgets/WidgetConsumer02.cs",
5268                    5,
5269                    "App.Widgets.IWidgetRepository",
5270                    "Widgets/IWidgetRepository.cs",
5271                ),
5272                ctor_di_to(
5273                    "Widgets/WidgetConsumer03.cs",
5274                    5,
5275                    "IWidgetRepository",
5276                    "plain",
5277                    "App.Widgets.WidgetRepository",
5278                ),
5279                uses_type(
5280                    "Widgets/WidgetConsumer03.cs",
5281                    5,
5282                    "App.Widgets.IWidgetRepository",
5283                    "Widgets/IWidgetRepository.cs",
5284                ),
5285                ctor_di_to(
5286                    "Widgets/WidgetConsumer04.cs",
5287                    5,
5288                    "IWidgetRepository",
5289                    "plain",
5290                    "App.Widgets.WidgetRepository",
5291                ),
5292                uses_type(
5293                    "Widgets/WidgetConsumer04.cs",
5294                    5,
5295                    "App.Widgets.IWidgetRepository",
5296                    "Widgets/IWidgetRepository.cs",
5297                ),
5298                ctor_di_to(
5299                    "Widgets/WidgetConsumer05.cs",
5300                    5,
5301                    "IWidgetRepository",
5302                    "plain",
5303                    "App.Widgets.WidgetRepository",
5304                ),
5305                uses_type(
5306                    "Widgets/WidgetConsumer05.cs",
5307                    5,
5308                    "App.Widgets.IWidgetRepository",
5309                    "Widgets/IWidgetRepository.cs",
5310                ),
5311                ctor_di_to(
5312                    "Widgets/WidgetConsumer06.cs",
5313                    5,
5314                    "IWidgetRepository",
5315                    "plain",
5316                    "App.Widgets.WidgetRepository",
5317                ),
5318                uses_type(
5319                    "Widgets/WidgetConsumer06.cs",
5320                    5,
5321                    "App.Widgets.IWidgetRepository",
5322                    "Widgets/IWidgetRepository.cs",
5323                ),
5324                ctor_di_to(
5325                    "Widgets/WidgetConsumer07.cs",
5326                    5,
5327                    "IWidgetRepository",
5328                    "plain",
5329                    "App.Widgets.WidgetRepository",
5330                ),
5331                uses_type(
5332                    "Widgets/WidgetConsumer07.cs",
5333                    5,
5334                    "App.Widgets.IWidgetRepository",
5335                    "Widgets/IWidgetRepository.cs",
5336                ),
5337                ctor_di_to(
5338                    "Widgets/WidgetConsumer08.cs",
5339                    5,
5340                    "IWidgetRepository",
5341                    "plain",
5342                    "App.Widgets.WidgetRepository",
5343                ),
5344                uses_type(
5345                    "Widgets/WidgetConsumer08.cs",
5346                    5,
5347                    "App.Widgets.IWidgetRepository",
5348                    "Widgets/IWidgetRepository.cs",
5349                ),
5350                ctor_di_to(
5351                    "Widgets/ClockConsumer0.cs",
5352                    5,
5353                    "IWidgetClock",
5354                    "plain",
5355                    "App.Widgets.WidgetRepository",
5356                ),
5357                uses_type(
5358                    "Widgets/ClockConsumer0.cs",
5359                    5,
5360                    "App.Widgets.IWidgetClock",
5361                    "Widgets/IWidgetClock.cs",
5362                ),
5363                ctor_di_to(
5364                    "Widgets/ClockConsumer1.cs",
5365                    5,
5366                    "IWidgetClock",
5367                    "plain",
5368                    "App.Widgets.WidgetRepository",
5369                ),
5370                uses_type(
5371                    "Widgets/ClockConsumer1.cs",
5372                    5,
5373                    "App.Widgets.IWidgetClock",
5374                    "Widgets/IWidgetClock.cs",
5375                ),
5376            ],
5377        )
5378    }
5379
5380    fn broad_iface_fixture_root() -> PathBuf {
5381        let root = temp_repo_root("broad-iface");
5382        write_manifest_fixture(&root, BROAD_IFACE_MANIFEST_FILES);
5383        root
5384    }
5385
5386    #[test]
5387    fn build_impact_model_brakes_a_broad_interface_by_fan_in_while_a_narrow_one_on_the_same_class_still_hops(
5388    ) {
5389        let graph = broad_iface_fixture_graph();
5390        let root = broad_iface_fixture_root();
5391        let index = load_graph_index(&graph, &root);
5392        assert_eq!(
5393            index.ctor_di_fanin.get("IWidgetRepository"),
5394            Some(&9),
5395            "fan-in counts distinct constructor sites"
5396        );
5397        assert_eq!(index.ctor_di_fanin.get("IWidgetClock"), Some(&2));
5398
5399        let model = match build_impact_model(
5400            &index,
5401            "WidgetRepository",
5402            1,
5403            DEFAULT_CAP,
5404            true,
5405            DEFAULT_IFACE_MAX_FANIN,
5406            DEFAULT_HUB_MAX_INDEGREE,
5407        ) {
5408            ImpactResult::Resolved(m) => m,
5409            other => panic!("expected Resolved, got {other:?}"),
5410        };
5411        let mut files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
5412        files.sort();
5413        assert_eq!(
5414            files,
5415            vec!["Widgets/ClockConsumer0.cs", "Widgets/ClockConsumer1.cs", "Widgets/GadgetService.cs"],
5416            "the broad contract is braked on BOTH widening paths; the narrow one and the direct-name edge are untouched"
5417        );
5418        assert_eq!(
5419            model.braked,
5420            vec![BrakedIface {
5421                iface: "IWidgetRepository".to_string(),
5422                fanin: 9
5423            }],
5424            "the narrowing is reported, never silent"
5425        );
5426    }
5427
5428    #[test]
5429    fn build_impact_model_iface_max_fanin_zero_disables_the_brake_and_restores_the_ds_0050_radius()
5430    {
5431        let graph = broad_iface_fixture_graph();
5432        let root = broad_iface_fixture_root();
5433        let index = load_graph_index(&graph, &root);
5434        let model = match build_impact_model(
5435            &index,
5436            "WidgetRepository",
5437            1,
5438            DEFAULT_CAP,
5439            true,
5440            0,
5441            DEFAULT_HUB_MAX_INDEGREE,
5442        ) {
5443            ImpactResult::Resolved(m) => m,
5444            other => panic!("expected Resolved, got {other:?}"),
5445        };
5446        assert_eq!(
5447            model.rows.len(),
5448            12,
5449            "every ctor-injected consumer of both contracts, plus the direct-name reference"
5450        );
5451        assert!(
5452            model.braked.is_empty(),
5453            "a brake that never fired reports nothing"
5454        );
5455
5456        // A threshold BELOW the narrow contract's own fan-in brakes it too,
5457        // widest first in the report -- the brake is a number, not a name list.
5458        let tight = match build_impact_model(
5459            &index,
5460            "WidgetRepository",
5461            1,
5462            DEFAULT_CAP,
5463            true,
5464            1,
5465            DEFAULT_HUB_MAX_INDEGREE,
5466        ) {
5467            ImpactResult::Resolved(m) => m,
5468            other => panic!("expected Resolved, got {other:?}"),
5469        };
5470        assert_eq!(
5471            tight
5472                .rows
5473                .iter()
5474                .map(|r| r.file.as_str())
5475                .collect::<Vec<_>>(),
5476            vec!["Widgets/GadgetService.cs"]
5477        );
5478        assert_eq!(
5479            tight.braked,
5480            vec![
5481                BrakedIface {
5482                    iface: "IWidgetRepository".to_string(),
5483                    fanin: 9
5484                },
5485                BrakedIface {
5486                    iface: "IWidgetClock".to_string(),
5487                    fanin: 2
5488                },
5489            ]
5490        );
5491    }
5492
5493    #[test]
5494    fn build_impact_model_no_iface_keeps_its_ds_0050_meaning_no_hop_at_all_and_no_brake_report() {
5495        let graph = broad_iface_fixture_graph();
5496        let root = broad_iface_fixture_root();
5497        let index = load_graph_index(&graph, &root);
5498        let model = match build_impact_model(
5499            &index,
5500            "WidgetRepository",
5501            1,
5502            DEFAULT_CAP,
5503            false,
5504            DEFAULT_IFACE_MAX_FANIN,
5505            DEFAULT_HUB_MAX_INDEGREE,
5506        ) {
5507            ImpactResult::Resolved(m) => m,
5508            other => panic!("expected Resolved, got {other:?}"),
5509        };
5510        assert_eq!(
5511            model
5512                .rows
5513                .iter()
5514                .map(|r| r.file.as_str())
5515                .collect::<Vec<_>>(),
5516            vec!["Widgets/GadgetService.cs"],
5517            "the hop is off entirely, so the narrow contract does not widen either"
5518        );
5519        assert!(
5520            model.braked.is_empty(),
5521            "nothing was braked because nothing was attempted"
5522        );
5523    }
5524
5525    // --- the hub-file brake ---
5526
5527    /// Two hub shapes reaching the same seed, each with its own consumers so
5528    /// the two brakes can be told apart: `Api/Startup.cs` is a hub by NAME (an
5529    /// entry point, whatever its in-degree), `Core/Shared.cs` only by
5530    /// IN-DEGREE. `Core/Plain.cs` is neither and must keep expanding.
5531    /// `Core/S5.cs`'s edge into `Core/Shared.cs` is a heuristic guess, so the
5532    /// in-degree it contributes is the proof that the index spans both edge
5533    /// kinds.
5534    fn hub_fixture_graph() -> graph::Graph {
5535        let mut defs = vec![
5536            def(
5537                "App.Core.Widget",
5538                "Widget",
5539                "App.Core",
5540                "class",
5541                "Core/Widget.cs",
5542                3,
5543            ),
5544            def(
5545                "App.Api.Startup",
5546                "Startup",
5547                "App.Api",
5548                "class",
5549                "Api/Startup.cs",
5550                3,
5551            ),
5552            def(
5553                "App.Core.Shared",
5554                "Shared",
5555                "App.Core",
5556                "class",
5557                "Core/Shared.cs",
5558                3,
5559            ),
5560            def(
5561                "App.Core.Plain",
5562                "Plain",
5563                "App.Core",
5564                "class",
5565                "Core/Plain.cs",
5566                3,
5567            ),
5568            def("App.Core.S5", "S5", "App.Core", "class", "Core/S5.cs", 3),
5569            def(
5570                "App.Core.PlainUser",
5571                "PlainUser",
5572                "App.Core",
5573                "class",
5574                "Core/PlainUser.cs",
5575                3,
5576            ),
5577        ];
5578        let mut edges = vec![
5579            uses_type("Api/Startup.cs", 10, "App.Core.Widget", "Core/Widget.cs"),
5580            uses_type("Core/Shared.cs", 10, "App.Core.Widget", "Core/Widget.cs"),
5581            uses_type("Core/Plain.cs", 10, "App.Core.Widget", "Core/Widget.cs"),
5582            heuristic_uses_type("Core/S5.cs", 4, "App.Core.Shared", "Core/Shared.cs"),
5583            uses_type("Core/PlainUser.cs", 4, "App.Core.Plain", "Core/Plain.cs"),
5584        ];
5585        for n in ["A", "B", "C", "D"] {
5586            defs.push(def(
5587                &format!("App.Api.{n}"),
5588                n,
5589                "App.Api",
5590                "class",
5591                &format!("Api/{n}.cs"),
5592                3,
5593            ));
5594            edges.push(uses_type(
5595                &format!("Api/{n}.cs"),
5596                4,
5597                "App.Api.Startup",
5598                "Api/Startup.cs",
5599            ));
5600        }
5601        for n in ["S1", "S2", "S3", "S4"] {
5602            defs.push(def(
5603                &format!("App.Core.{n}"),
5604                n,
5605                "App.Core",
5606                "class",
5607                &format!("Core/{n}.cs"),
5608                3,
5609            ));
5610            edges.push(uses_type(
5611                &format!("Core/{n}.cs"),
5612                4,
5613                "App.Core.Shared",
5614                "Core/Shared.cs",
5615            ));
5616        }
5617        make_graph(defs, edges)
5618    }
5619
5620    fn hub_fixture_root() -> PathBuf {
5621        let root = temp_repo_root("hub-file");
5622        let mut files: Vec<String> = vec![
5623            "Core/Widget.cs".into(),
5624            "Api/Startup.cs".into(),
5625            "Core/Shared.cs".into(),
5626            "Core/Plain.cs".into(),
5627            "Core/PlainUser.cs".into(),
5628            "Core/S5.cs".into(),
5629        ];
5630        for n in ["A", "B", "C", "D"] {
5631            files.push(format!("Api/{n}.cs"));
5632        }
5633        for n in ["S1", "S2", "S3", "S4"] {
5634            files.push(format!("Core/{n}.cs"));
5635        }
5636        let refs: Vec<&str> = files.iter().map(String::as_str).collect();
5637        write_manifest_fixture(&root, &refs);
5638        root
5639    }
5640
5641    fn hub_files_of(model: &ImpactModel) -> Vec<String> {
5642        let mut files: Vec<String> = model.rows.iter().map(|r| r.file.clone()).collect();
5643        files.sort();
5644        files
5645    }
5646
5647    #[test]
5648    fn load_graph_index_hub_indegree_counts_distinct_referring_files_across_both_edge_kinds() {
5649        let graph = hub_fixture_graph();
5650        let root = hub_fixture_root();
5651        let index = load_graph_index(&graph, &root);
5652        assert_eq!(index.hub_indegree.get("Api/Startup.cs").copied(), Some(4));
5653        assert_eq!(
5654            index.hub_indegree.get("Core/Shared.cs").copied(),
5655            Some(5),
5656            "the heuristic referrer counts too -- a hub is reached by either kind"
5657        );
5658        assert_eq!(index.hub_indegree.get("Core/Plain.cs").copied(), Some(1));
5659        assert!(
5660            index.hub_indegree.get("Api/A.cs").is_none(),
5661            "a file nothing references carries no entry at all"
5662        );
5663    }
5664
5665    #[test]
5666    fn build_impact_model_a_hub_file_is_recorded_classed_infra_and_never_expanded_through() {
5667        let graph = hub_fixture_graph();
5668        let root = hub_fixture_root();
5669        let index = load_graph_index(&graph, &root);
5670        let model = match build_impact_model(
5671            &index,
5672            "Widget",
5673            2,
5674            DEFAULT_CAP,
5675            true,
5676            DEFAULT_IFACE_MAX_FANIN,
5677            DEFAULT_HUB_MAX_INDEGREE,
5678        ) {
5679            ImpactResult::Resolved(m) => m,
5680            other => panic!("expected Resolved, got {other:?}"),
5681        };
5682        assert_eq!(
5683            hub_files_of(&model),
5684            vec![
5685                "Api/Startup.cs",
5686                "Core/Plain.cs",
5687                "Core/PlainUser.cs",
5688                "Core/S1.cs",
5689                "Core/S2.cs",
5690                "Core/S3.cs",
5691                "Core/S4.cs",
5692                "Core/S5.cs",
5693                "Core/Shared.cs"
5694            ],
5695            "the entry point is reached but its own four consumers are not"
5696        );
5697        let row_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap();
5698        assert!(
5699            row_of("Api/Startup.cs").infra,
5700            "the row says why the walk stopped there"
5701        );
5702        assert!(
5703            !row_of("Core/Shared.cs").infra,
5704            "an ordinary file carries no class key at all"
5705        );
5706        assert_eq!(
5707            model.braked_files,
5708            vec![BrakedFile {
5709                file: "Api/Startup.cs".to_string(),
5710                indegree: 4
5711            }]
5712        );
5713        assert!(model.braked.is_empty(), "no interface was braked here");
5714    }
5715
5716    #[test]
5717    fn build_impact_model_hub_max_indegree_brakes_a_file_no_name_pattern_matches_and_zero_disables_that_half_only(
5718    ) {
5719        let graph = hub_fixture_graph();
5720        let root = hub_fixture_root();
5721        let index = load_graph_index(&graph, &root);
5722        let tight = match build_impact_model(
5723            &index,
5724            "Widget",
5725            2,
5726            DEFAULT_CAP,
5727            true,
5728            DEFAULT_IFACE_MAX_FANIN,
5729            5,
5730        ) {
5731            ImpactResult::Resolved(m) => m,
5732            other => panic!("expected Resolved, got {other:?}"),
5733        };
5734        assert_eq!(
5735            hub_files_of(&tight),
5736            vec![
5737                "Api/Startup.cs",
5738                "Core/Plain.cs",
5739                "Core/PlainUser.cs",
5740                "Core/Shared.cs"
5741            ],
5742            "the in-degree-5 file stops expanding too"
5743        );
5744        assert_eq!(
5745            tight.braked_files,
5746            vec![
5747                BrakedFile {
5748                    file: "Core/Shared.cs".to_string(),
5749                    indegree: 5
5750                },
5751                BrakedFile {
5752                    file: "Api/Startup.cs".to_string(),
5753                    indegree: 4
5754                },
5755            ],
5756            "widest-first, then by path"
5757        );
5758
5759        let off = match build_impact_model(
5760            &index,
5761            "Widget",
5762            2,
5763            DEFAULT_CAP,
5764            true,
5765            DEFAULT_IFACE_MAX_FANIN,
5766            0,
5767        ) {
5768            ImpactResult::Resolved(m) => m,
5769            other => panic!("expected Resolved, got {other:?}"),
5770        };
5771        assert_eq!(
5772            off.braked_files,
5773            vec![BrakedFile { file: "Api/Startup.cs".to_string(), indegree: 4 }],
5774            "0 disables the threshold; the name-pattern classification is not a threshold and stays on"
5775        );
5776        assert!(
5777            hub_files_of(&off).contains(&"Core/S1.cs".to_string()),
5778            "the in-degree hub widens again"
5779        );
5780        assert!(
5781            !hub_files_of(&off).contains(&"Api/A.cs".to_string()),
5782            "an entry point is still an entry point at --hub-max-indegree 0"
5783        );
5784    }
5785
5786    #[test]
5787    fn build_impact_model_a_hub_reached_on_the_last_hop_is_never_reported_as_braked() {
5788        let graph = hub_fixture_graph();
5789        let root = hub_fixture_root();
5790        let index = load_graph_index(&graph, &root);
5791        let model = match build_impact_model(
5792            &index,
5793            "Widget",
5794            1,
5795            DEFAULT_CAP,
5796            true,
5797            DEFAULT_IFACE_MAX_FANIN,
5798            DEFAULT_HUB_MAX_INDEGREE,
5799        ) {
5800            ImpactResult::Resolved(m) => m,
5801            other => panic!("expected Resolved, got {other:?}"),
5802        };
5803        assert_eq!(
5804            hub_files_of(&model),
5805            vec!["Api/Startup.cs", "Core/Plain.cs", "Core/Shared.cs"]
5806        );
5807        assert!(
5808            model.rows.iter().find(|r| r.file == "Api/Startup.cs").unwrap().infra,
5809            "the classification is a fact about the file, not about whether the walk had another hop left"
5810        );
5811        assert!(
5812            model.braked_files.is_empty(),
5813            "no hop remained, so no widening was refused"
5814        );
5815    }
5816
5817    #[test]
5818    fn is_infra_file_matches_the_four_shapes_and_nothing_that_merely_resembles_them() {
5819        for f in [
5820            "src/Program.cs",
5821            "Startup.cs",
5822            "a/CatalogServiceExtensions.cs",
5823            "a/FooServiceCollectionExtensions.cs",
5824            "a/JobQueueRegistration.cs",
5825            "a/DependencyResolution/Wire.cs",
5826            "a/CompositionRootTests.cs",
5827            "a/GroupControllerTestsBase.cs",
5828            "a/ControllerTestBase.cs",
5829            "a/BaseFixture.cs",
5830        ] {
5831            assert!(is_infra_file(f), "{f} must classify as infra");
5832        }
5833        for f in [
5834            "src/ProgramManager.cs",
5835            "src/StartupRunner.cs",
5836            "src/Registrations.cs",
5837            "src/DependencyResolutionHelper.cs",
5838            "src/CompositionRoot.Extra.cs",
5839            "src/Widget.cs",
5840        ] {
5841            assert!(!is_infra_file(f), "{f} must NOT classify as infra");
5842        }
5843    }
5844
5845    // --- the per-kind referencing line on an impact row ---
5846
5847    const FROM_LINES_MANIFEST_FILES: &[&str] = &[
5848        "Pay/IPaymentGateway.cs",
5849        "Pay/StripeGateway.cs",
5850        "Pay/Mixed.cs",
5851        "Pay/GuessOnly.cs",
5852    ];
5853
5854    /// One consumer file reached by ALL FOUR kinds the walk distinguishes, each
5855    /// at its own line: two direct `uses-type` refs (lines 5 and 12 -- the
5856    /// lower one must win), a `ctor-di` edge (line 7) plus its companion plain
5857    /// ref at the identical site (deduped away, never a second kind), a direct
5858    /// interface-name ref (line 20), and a heuristic guess (line 30).
5859    fn from_lines_fixture_graph() -> graph::Graph {
5860        make_graph(
5861            vec![
5862                def(
5863                    "App.Pay.IPaymentGateway",
5864                    "IPaymentGateway",
5865                    "App.Pay",
5866                    "interface",
5867                    "Pay/IPaymentGateway.cs",
5868                    3,
5869                ),
5870                def(
5871                    "App.Pay.StripeGateway",
5872                    "StripeGateway",
5873                    "App.Pay",
5874                    "class",
5875                    "Pay/StripeGateway.cs",
5876                    3,
5877                ),
5878                def(
5879                    "App.Pay.Mixed",
5880                    "Mixed",
5881                    "App.Pay",
5882                    "class",
5883                    "Pay/Mixed.cs",
5884                    3,
5885                ),
5886                def(
5887                    "App.Pay.GuessOnly",
5888                    "GuessOnly",
5889                    "App.Pay",
5890                    "class",
5891                    "Pay/GuessOnly.cs",
5892                    3,
5893                ),
5894            ],
5895            vec![
5896                inherits(
5897                    "Pay/StripeGateway.cs",
5898                    3,
5899                    "App.Pay.IPaymentGateway",
5900                    "Pay/IPaymentGateway.cs",
5901                ),
5902                uses_type(
5903                    "Pay/Mixed.cs",
5904                    12,
5905                    "App.Pay.StripeGateway",
5906                    "Pay/StripeGateway.cs",
5907                ),
5908                uses_type(
5909                    "Pay/Mixed.cs",
5910                    5,
5911                    "App.Pay.StripeGateway",
5912                    "Pay/StripeGateway.cs",
5913                ),
5914                ctor_di_to(
5915                    "Pay/Mixed.cs",
5916                    7,
5917                    "IPaymentGateway",
5918                    "plain",
5919                    "App.Pay.StripeGateway",
5920                ),
5921                uses_type(
5922                    "Pay/Mixed.cs",
5923                    7,
5924                    "App.Pay.IPaymentGateway",
5925                    "Pay/IPaymentGateway.cs",
5926                ),
5927                uses_type(
5928                    "Pay/Mixed.cs",
5929                    20,
5930                    "App.Pay.IPaymentGateway",
5931                    "Pay/IPaymentGateway.cs",
5932                ),
5933                heuristic_uses_member(
5934                    "Pay/Mixed.cs",
5935                    30,
5936                    "App.Pay.StripeGateway",
5937                    "Pay/StripeGateway.cs",
5938                ),
5939                heuristic_uses_member(
5940                    "Pay/GuessOnly.cs",
5941                    9,
5942                    "App.Pay.StripeGateway",
5943                    "Pay/StripeGateway.cs",
5944                ),
5945            ],
5946        )
5947    }
5948
5949    fn from_lines_fixture_root() -> PathBuf {
5950        let root = temp_repo_root("from-lines");
5951        write_manifest_fixture(&root, FROM_LINES_MANIFEST_FILES);
5952        root
5953    }
5954
5955    #[test]
5956    fn build_impact_model_names_one_referencing_line_per_edge_kind_lowest_line_per_kind() {
5957        let graph = from_lines_fixture_graph();
5958        let root = from_lines_fixture_root();
5959        let index = load_graph_index(&graph, &root);
5960        let model = match build_impact_model(
5961            &index,
5962            "StripeGateway",
5963            1,
5964            DEFAULT_CAP,
5965            true,
5966            DEFAULT_IFACE_MAX_FANIN,
5967            DEFAULT_HUB_MAX_INDEGREE,
5968        ) {
5969            ImpactResult::Resolved(m) => m,
5970            other => panic!("expected Resolved, got {other:?}"),
5971        };
5972        let row_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap();
5973        assert_eq!(
5974            row_of("Pay/Mixed.cs").from_lines,
5975            vec![
5976                ("direct", 5),
5977                ("ctor-di", 7),
5978                ("heuristic", 30),
5979                ("iface", 20)
5980            ],
5981            "key order is the walk's own kind declaration order, never a map iteration"
5982        );
5983        assert_eq!(
5984            row_of("Pay/Mixed.cs").via_count,
5985            4,
5986            "the companion ref at the ctor-di site is still deduped, not a fifth hit"
5987        );
5988        assert_eq!(
5989            row_of("Pay/GuessOnly.cs").from_lines,
5990            vec![("heuristic", 9)]
5991        );
5992    }
5993
5994    #[test]
5995    fn build_impact_model_no_iface_drops_the_two_interface_hop_kinds_and_keeps_the_rest() {
5996        let graph = from_lines_fixture_graph();
5997        let root = from_lines_fixture_root();
5998        let index = load_graph_index(&graph, &root);
5999        let model = match build_impact_model(
6000            &index,
6001            "StripeGateway",
6002            1,
6003            DEFAULT_CAP,
6004            false,
6005            DEFAULT_IFACE_MAX_FANIN,
6006            DEFAULT_HUB_MAX_INDEGREE,
6007        ) {
6008            ImpactResult::Resolved(m) => m,
6009            other => panic!("expected Resolved, got {other:?}"),
6010        };
6011        let row = model
6012            .rows
6013            .iter()
6014            .find(|r| r.file == "Pay/Mixed.cs")
6015            .unwrap();
6016        assert_eq!(
6017            row.from_lines,
6018            vec![("direct", 5), ("heuristic", 30)],
6019            "no hop was attempted, so neither interface kind may claim a line"
6020        );
6021    }
6022
6023    #[test]
6024    fn build_impact_model_an_ambiguous_only_hit_still_names_its_line_under_the_direct_kind() {
6025        let graph = base_fixture_graph();
6026        let root = base_fixture_root();
6027        let index = load_graph_index(&graph, &root);
6028        let model = match build_impact_model(
6029            &index,
6030            "One/Config.cs",
6031            1,
6032            DEFAULT_CAP,
6033            true,
6034            DEFAULT_IFACE_MAX_FANIN,
6035            DEFAULT_HUB_MAX_INDEGREE,
6036        ) {
6037            ImpactResult::Resolved(m) => m,
6038            other => panic!("expected Resolved, got {other:?}"),
6039        };
6040        let row = model
6041            .rows
6042            .iter()
6043            .find(|r| r.file == "Three/Consumer.cs")
6044            .unwrap();
6045        assert_eq!(row.ambiguous_count, 1);
6046        assert_eq!(
6047            row.from_lines,
6048            vec![("direct", 4)],
6049            "refs' own tie-break: an ambiguous site is used only when no resolved one exists"
6050        );
6051    }
6052
6053    #[test]
6054    fn build_impact_model_a_row_no_kind_could_attribute_a_line_to_carries_no_from_lines_at_all() {
6055        let graph = make_graph(
6056            vec![
6057                def("App.A.Seed", "Seed", "App.A", "class", "A/Seed.cs", 1),
6058                def("App.A.User", "User", "App.A", "class", "A/User.cs", 1),
6059            ],
6060            vec![uses_type("A/User.cs", 0, "App.A.Seed", "A/Seed.cs")],
6061        );
6062        let root = temp_repo_root("from-lines-empty");
6063        write_manifest_fixture(&root, &["A/Seed.cs", "A/User.cs"]);
6064        let index = load_graph_index(&graph, &root);
6065        let model = match build_impact_model(
6066            &index,
6067            "Seed",
6068            1,
6069            DEFAULT_CAP,
6070            true,
6071            DEFAULT_IFACE_MAX_FANIN,
6072            DEFAULT_HUB_MAX_INDEGREE,
6073        ) {
6074            ImpactResult::Resolved(m) => m,
6075            other => panic!("expected Resolved, got {other:?}"),
6076        };
6077        assert_eq!(
6078            model
6079                .rows
6080                .iter()
6081                .map(|r| r.file.as_str())
6082                .collect::<Vec<_>>(),
6083            vec!["A/User.cs"]
6084        );
6085        assert!(
6086            model.rows[0].from_lines.is_empty(),
6087            "a line-less edge adds no key, exactly like every other conditional field"
6088        );
6089    }
6090
6091    // --- 14: enum-member resolve_symbol by id and by unique simple name ---
6092
6093    #[test]
6094    fn resolve_symbol_finds_enum_member_by_id_and_unique_simple_name() {
6095        let graph = enum_fixture_graph();
6096        let root = enum_fixture_root();
6097        let index = load_graph_index(&graph, &root);
6098        assert_eq!(
6099            resolve_symbol(&index, "App.Enums.PostType.Question"),
6100            Resolution::Resolved("App.Enums.PostType.Question".into())
6101        );
6102        assert_eq!(
6103            resolve_symbol(&index, "Question"),
6104            Resolution::Resolved("App.Enums.PostType.Question".into())
6105        );
6106    }
6107
6108    // --- the Enum.Member tail and the member-count split ---
6109
6110    #[test]
6111    fn resolve_symbol_accepts_a_dotted_tail_of_a_def_id_and_refuses_a_shared_one() {
6112        let graph = enum_fixture_graph();
6113        let root = enum_fixture_root();
6114        let index = load_graph_index(&graph, &root);
6115        assert_eq!(
6116            resolve_symbol(&index, "PostType.Question"),
6117            Resolution::Resolved("App.Enums.PostType.Question".into()),
6118            "the spelling a caller reaches for -- never the namespace-qualified id the graph keys it under"
6119        );
6120        assert_eq!(
6121            resolve_symbol(&index, "Enums.PostType"),
6122            Resolution::Resolved("App.Enums.PostType".into()),
6123            "the tail rule is not enum-specific: any dotted suffix of exactly one def id resolves"
6124        );
6125        assert_eq!(
6126            resolve_symbol(&index, "PostType.Missing"),
6127            Resolution::NotFound
6128        );
6129
6130        let two = make_graph(
6131            vec![
6132                def("App.One.Mode", "Mode", "App.One", "enum", "One/Mode.cs", 1),
6133                def(
6134                    "App.One.Mode.Fast",
6135                    "Fast",
6136                    "App.One",
6137                    "enum-member",
6138                    "One/Mode.cs",
6139                    2,
6140                ),
6141                def("App.Two.Mode", "Mode", "App.Two", "enum", "Two/Mode.cs", 1),
6142                def(
6143                    "App.Two.Mode.Fast",
6144                    "Fast",
6145                    "App.Two",
6146                    "enum-member",
6147                    "Two/Mode.cs",
6148                    2,
6149                ),
6150            ],
6151            vec![],
6152        );
6153        let two_root = temp_repo_root("enum-tail-ambiguous");
6154        write_manifest_fixture(&two_root, &["One/Mode.cs", "Two/Mode.cs"]);
6155        let two_index = load_graph_index(&two, &two_root);
6156        assert_eq!(
6157            resolve_symbol(&two_index, "Mode.Fast"),
6158            Resolution::Ambiguous(vec!["App.One.Mode.Fast".into(), "App.Two.Mode.Fast".into()])
6159        );
6160    }
6161
6162    #[test]
6163    fn build_refs_model_on_an_enum_appends_member_refs_in_declaration_order() {
6164        let graph = make_graph(
6165            vec![
6166                def(
6167                    "App.Flags.Toggles",
6168                    "Toggles",
6169                    "App.Flags",
6170                    "enum",
6171                    "Flags/Toggles.cs",
6172                    3,
6173                ),
6174                def(
6175                    "App.Flags.Toggles.EnableX",
6176                    "EnableX",
6177                    "App.Flags",
6178                    "enum-member",
6179                    "Flags/Toggles.cs",
6180                    5,
6181                ),
6182                def(
6183                    "App.Flags.Toggles.EnableY",
6184                    "EnableY",
6185                    "App.Flags",
6186                    "enum-member",
6187                    "Flags/Toggles.cs",
6188                    6,
6189                ),
6190                def(
6191                    "App.Flags.Toggles.EnableZ",
6192                    "EnableZ",
6193                    "App.Flags",
6194                    "enum-member",
6195                    "Flags/Toggles.cs",
6196                    7,
6197                ),
6198                def(
6199                    "App.Run.Runner",
6200                    "Runner",
6201                    "App.Run",
6202                    "class",
6203                    "Run/Runner.cs",
6204                    3,
6205                ),
6206            ],
6207            vec![
6208                uses_type("Run/Runner.cs", 5, "App.Flags.Toggles", "Flags/Toggles.cs"),
6209                uses_member(
6210                    "Run/Runner.cs",
6211                    6,
6212                    "App.Flags.Toggles.EnableY",
6213                    "Flags/Toggles.cs",
6214                ),
6215                uses_member(
6216                    "Run/Runner.cs",
6217                    7,
6218                    "App.Flags.Toggles.EnableX",
6219                    "Flags/Toggles.cs",
6220                ),
6221                uses_member(
6222                    "Run/Runner.cs",
6223                    8,
6224                    "App.Flags.Toggles.EnableX",
6225                    "Flags/Toggles.cs",
6226                ),
6227            ],
6228        );
6229        let root = temp_repo_root("enum-member-refs");
6230        write_manifest_fixture(&root, &["Flags/Toggles.cs", "Run/Runner.cs"]);
6231        let index = load_graph_index(&graph, &root);
6232
6233        let model = match build_refs_model(
6234            &index,
6235            "Toggles",
6236            false,
6237            DEFAULT_CAP,
6238            INBOUND_CAP,
6239            OUTBOUND_CAP,
6240            false,
6241        ) {
6242            RefsResult::Resolved(m) => m,
6243            other => panic!("expected Resolved, got {other:?}"),
6244        };
6245        assert_eq!(
6246            model.member_refs,
6247            Some(MemberRefs {
6248                total: 3,
6249                member_count: 2,
6250                members: vec![
6251                    MemberRefEntry { name: "EnableX".into(), count: 2 },
6252                    MemberRefEntry { name: "EnableY".into(), count: 1 },
6253                ],
6254                dropped: 0,
6255            }),
6256            "declaration order, never count order, and a member nothing references is left out entirely"
6257        );
6258        assert_eq!(
6259            model.inbound.uses_member.total, 3,
6260            "the existing union is unchanged by the split"
6261        );
6262
6263        let other = match build_refs_model(
6264            &index,
6265            "Runner",
6266            false,
6267            DEFAULT_CAP,
6268            INBOUND_CAP,
6269            OUTBOUND_CAP,
6270            false,
6271        ) {
6272            RefsResult::Resolved(m) => m,
6273            other => panic!("expected Resolved, got {other:?}"),
6274        };
6275        assert_eq!(
6276            other.member_refs, None,
6277            "nothing but an enum carries the field"
6278        );
6279    }
6280
6281    #[test]
6282    fn build_impact_model_on_an_enum_file_reaches_files_that_use_only_its_members() {
6283        let graph = make_graph(
6284            vec![
6285                def(
6286                    "App.Flags.Toggles",
6287                    "Toggles",
6288                    "App.Flags",
6289                    "enum",
6290                    "Flags/Toggles.cs",
6291                    3,
6292                ),
6293                def(
6294                    "App.Flags.Toggles.EnableX",
6295                    "EnableX",
6296                    "App.Flags",
6297                    "enum-member",
6298                    "Flags/Toggles.cs",
6299                    5,
6300                ),
6301                def(
6302                    "App.Run.Runner",
6303                    "Runner",
6304                    "App.Run",
6305                    "class",
6306                    "Run/Runner.cs",
6307                    3,
6308                ),
6309            ],
6310            vec![uses_member(
6311                "Run/Runner.cs",
6312                6,
6313                "App.Flags.Toggles.EnableX",
6314                "Flags/Toggles.cs",
6315            )],
6316        );
6317        let root = temp_repo_root("enum-member-impact");
6318        write_manifest_fixture(&root, &["Flags/Toggles.cs", "Run/Runner.cs"]);
6319        let index = load_graph_index(&graph, &root);
6320        let model = match build_impact_model(
6321            &index,
6322            "Flags/Toggles.cs",
6323            2,
6324            DEFAULT_CAP,
6325            true,
6326            DEFAULT_IFACE_MAX_FANIN,
6327            DEFAULT_HUB_MAX_INDEGREE,
6328        ) {
6329            ImpactResult::Resolved(m) => m,
6330            other => panic!("expected Resolved, got {other:?}"),
6331        };
6332        assert_eq!(
6333            model.rows.iter().map(|r| (r.file.as_str(), r.top_symbols.clone())).collect::<Vec<_>>(),
6334            vec![("Run/Runner.cs", vec!["EnableX".to_string()])],
6335            "the member def is a def of the seed FILE, so a member-only consumer is still in the blast radius"
6336        );
6337    }
6338
6339    // --- 15: build_refs_model on an enum member ---
6340
6341    #[test]
6342    fn build_refs_model_on_enum_member_def_site_plus_inbound_uses_member() {
6343        let graph = enum_fixture_graph();
6344        let root = enum_fixture_root();
6345        let index = load_graph_index(&graph, &root);
6346        let model = match build_refs_model(
6347            &index,
6348            "Question",
6349            true,
6350            DEFAULT_CAP,
6351            INBOUND_CAP,
6352            OUTBOUND_CAP,
6353            false,
6354        ) {
6355            RefsResult::Resolved(m) => m,
6356            other => panic!("expected Resolved, got {other:?}"),
6357        };
6358        assert_eq!(model.kind, "enum-member");
6359        assert_eq!(
6360            model.sites,
6361            vec![DefSite {
6362                file: "Enums/PostType.cs".into(),
6363                line: 6
6364            }]
6365        );
6366        assert_eq!(model.inbound.uses_member.total, 1);
6367        assert_eq!(
6368            model.inbound.uses_member.rows[0].file,
6369            "Consumers/Reader.cs"
6370        );
6371        assert_eq!(model.inbound.uses_member.rows[0].line, 8);
6372    }
6373
6374    // --- 16: build_refs_model on the enum itself: members' inbound unions in ---
6375
6376    #[test]
6377    fn build_refs_model_on_enum_itself_unions_members_inbound_uses_member() {
6378        let graph = enum_fixture_graph();
6379        let root = enum_fixture_root();
6380        let index = load_graph_index(&graph, &root);
6381        let model = match build_refs_model(
6382            &index,
6383            "PostType",
6384            true,
6385            DEFAULT_CAP,
6386            INBOUND_CAP,
6387            OUTBOUND_CAP,
6388            false,
6389        ) {
6390            RefsResult::Resolved(m) => m,
6391            other => panic!("expected Resolved, got {other:?}"),
6392        };
6393        assert_eq!(model.kind, "enum");
6394        assert_eq!(
6395            model.inbound.uses_member.total, 1,
6396            "member-level access must surface on the enum query"
6397        );
6398        assert_eq!(
6399            model.inbound.uses_member.rows[0].file,
6400            "Consumers/Reader.cs"
6401        );
6402        assert_eq!(model.inbound.uses_member.rows[0].line, 8);
6403    }
6404
6405    // --- 17: two enums, each with a same-named member -> ambiguous ---
6406
6407    #[test]
6408    fn two_enums_with_same_named_member_resolve_ambiguous_both_sites_surfaced() {
6409        let graph = make_graph(
6410            vec![
6411                def(
6412                    "App.One.StatusEnum",
6413                    "StatusEnum",
6414                    "App.One",
6415                    "enum",
6416                    "One/StatusEnum.cs",
6417                    1,
6418                ),
6419                def(
6420                    "App.One.StatusEnum.Changed",
6421                    "Changed",
6422                    "App.One",
6423                    "enum-member",
6424                    "One/StatusEnum.cs",
6425                    2,
6426                ),
6427                def(
6428                    "App.Two.OtherEnum",
6429                    "OtherEnum",
6430                    "App.Two",
6431                    "enum",
6432                    "Two/OtherEnum.cs",
6433                    1,
6434                ),
6435                def(
6436                    "App.Two.OtherEnum.Changed",
6437                    "Changed",
6438                    "App.Two",
6439                    "enum-member",
6440                    "Two/OtherEnum.cs",
6441                    2,
6442                ),
6443            ],
6444            vec![],
6445        );
6446        let root = temp_repo_root("two-enums-changed");
6447        write_manifest_fixture(&root, &["One/StatusEnum.cs", "Two/OtherEnum.cs"]);
6448        let index = load_graph_index(&graph, &root);
6449
6450        match resolve_symbol(&index, "Changed") {
6451            Resolution::Ambiguous(mut ids) => {
6452                ids.sort();
6453                assert_eq!(
6454                    ids,
6455                    vec![
6456                        "App.One.StatusEnum.Changed".to_string(),
6457                        "App.Two.OtherEnum.Changed".to_string()
6458                    ]
6459                );
6460            }
6461            other => panic!("expected Ambiguous, got {other:?}"),
6462        }
6463
6464        match build_refs_model(
6465            &index,
6466            "Changed",
6467            true,
6468            DEFAULT_CAP,
6469            INBOUND_CAP,
6470            OUTBOUND_CAP,
6471            false,
6472        ) {
6473            RefsResult::Ambiguous(mut ids) => {
6474                ids.sort();
6475                assert_eq!(
6476                    ids,
6477                    vec![
6478                        "App.One.StatusEnum.Changed".to_string(),
6479                        "App.Two.OtherEnum.Changed".to_string()
6480                    ]
6481                );
6482            }
6483            other => panic!("expected Ambiguous, got {other:?}"),
6484        }
6485    }
6486
6487    // --- 18: uses-member edge counts toward 1-hop blast radius ---
6488
6489    #[test]
6490    fn build_impact_model_uses_member_edge_counts_toward_one_hop_blast_radius() {
6491        let graph = enum_fixture_graph();
6492        let root = enum_fixture_root();
6493        let index = load_graph_index(&graph, &root);
6494        let model = match build_impact_model(
6495            &index,
6496            "Question",
6497            1,
6498            DEFAULT_CAP,
6499            true,
6500            DEFAULT_IFACE_MAX_FANIN,
6501            DEFAULT_HUB_MAX_INDEGREE,
6502        ) {
6503            ImpactResult::Resolved(m) => m,
6504            other => panic!("expected Resolved, got {other:?}"),
6505        };
6506        assert_eq!(
6507            model
6508                .rows
6509                .iter()
6510                .map(|r| r.file.clone())
6511                .collect::<Vec<_>>(),
6512            vec!["Consumers/Reader.cs".to_string()]
6513        );
6514        assert_eq!(model.rows[0].hop, 1);
6515    }
6516
6517    // --- 19: uses-member edge propagates a second hop through the ordinary type-ref graph ---
6518
6519    #[test]
6520    fn build_impact_model_uses_member_edge_propagates_second_hop() {
6521        let graph = enum_fixture_graph();
6522        let root = enum_fixture_root();
6523        let index = load_graph_index(&graph, &root);
6524        let model = match build_impact_model(
6525            &index,
6526            "Question",
6527            2,
6528            DEFAULT_CAP,
6529            true,
6530            DEFAULT_IFACE_MAX_FANIN,
6531            DEFAULT_HUB_MAX_INDEGREE,
6532        ) {
6533            ImpactResult::Resolved(m) => m,
6534            other => panic!("expected Resolved, got {other:?}"),
6535        };
6536        let mut files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
6537        files.sort();
6538        assert_eq!(files, vec!["Consumers/Reader.cs", "Consumers/TwoHop.cs"]);
6539        assert_eq!(
6540            model
6541                .rows
6542                .iter()
6543                .find(|r| r.file == "Consumers/TwoHop.cs")
6544                .unwrap()
6545                .hop,
6546            2
6547        );
6548    }
6549
6550    // --- 20: impact_walk + personalized_page_rank -- finite, non-negative, never NaN ---
6551
6552    #[test]
6553    fn impact_walk_and_ppr_never_negative_or_nan() {
6554        let graph = base_fixture_graph();
6555        let root = base_fixture_root();
6556        let index = load_graph_index(&graph, &root);
6557        let walk = impact_walk(
6558            &index,
6559            &["App.Widgets.IWidget".to_string()],
6560            2,
6561            true,
6562            DEFAULT_IFACE_MAX_FANIN,
6563            DEFAULT_HUB_MAX_INDEGREE,
6564        );
6565        let mut nodes: SeqSet<String> = SeqSet::new();
6566        for f in walk.seed_files.iter() {
6567            nodes.insert(f.clone());
6568        }
6569        for f in walk.visited.keys() {
6570            nodes.insert(f.clone());
6571        }
6572        let seeds: Vec<String> = walk.seed_files.iter().cloned().collect();
6573        let rank = personalized_page_rank(
6574            &nodes.into_vec(),
6575            &walk.fwd_adj,
6576            &seeds,
6577            DEFAULT_DAMPING,
6578            DEFAULT_ITERATIONS,
6579        );
6580        for v in rank.values() {
6581            assert!(
6582                v.is_finite() && *v >= 0.0,
6583                "every rank must be a finite, non-negative number, got {v}"
6584            );
6585        }
6586    }
6587
6588    // --- extra: looks_like_file_path pinned directly (small and worth
6589    // documenting explicitly, incl. the qualified-id quirk). ---
6590
6591    #[test]
6592    fn looks_like_file_path_matches_js_regex_semantics() {
6593        assert!(looks_like_file_path("Widgets/IWidget.cs"));
6594        assert!(looks_like_file_path("IWidget.cs"));
6595        assert!(!looks_like_file_path("IWidget"));
6596        assert!(
6597            looks_like_file_path("App.Widgets.IWidget"),
6598            "qualified id with alnum trailing segment matches the regex, same as JS"
6599        );
6600        assert!(
6601            !looks_like_file_path("Foo."),
6602            "trailing dot with nothing after it does not match (empty extension)"
6603        );
6604        assert!(
6605            !looks_like_file_path("Foo!"),
6606            "no dot-then-alnum-to-end anywhere"
6607        );
6608    }
6609
6610    // ========================================================================
6611    // The CLI contract for heuristic edges. The shapes pinned here (row order,
6612    // the shared cap, which edges seed a hop, the count line) live in this
6613    // module plus render.rs, so these tests drive THOSE directly -- same
6614    // fixtures, same expected literals, no process spawn.
6615    //
6616    // A hand-written graph rather than a `devscout map` of C# sources on
6617    // purpose: driving these from source would mean building a fixture whose
6618    // resolution happens to produce 51 inbound edges. The resolver's own tests
6619    // own the question of WHICH edges get tagged; these own what the
6620    // query+render layers do once they are.
6621    // ========================================================================
6622
6623    fn widget_def() -> graph::Def {
6624        graph::Def {
6625            id: "App.Core.Widget".into(),
6626            name: "Widget".into(),
6627            namespace: "App.Core".into(),
6628            kind: "class".into(),
6629            file: "Core/Widget.cs".into(),
6630            line: 3,
6631            methods: vec!["Render".to_string()],
6632            test_methods: vec![],
6633            also_in: vec![],
6634            end_line: 0,
6635        }
6636    }
6637
6638    /// A manifest listing every def file and every edge's from_file, so
6639    /// `manifest_gap` stays 0 and the rendered bytes carry no trailing gap line.
6640    fn stage4_root(defs: &[graph::Def], edges: &[graph::Edge], label: &str) -> PathBuf {
6641        let root = temp_repo_root(label);
6642        let mut files: Vec<String> = Vec::new();
6643        for d in defs {
6644            if !files.contains(&d.file) {
6645                files.push(d.file.clone());
6646            }
6647        }
6648        for e in edges {
6649            let f = edge_loc(e).0.to_string();
6650            if !files.contains(&f) {
6651                files.push(f);
6652            }
6653        }
6654        let refs: Vec<&str> = files.iter().map(String::as_str).collect();
6655        write_manifest_fixture(&root, &refs);
6656        root
6657    }
6658
6659    #[test]
6660    fn stage4_cli_refs_lists_every_precise_row_before_any_heuristic_row_and_suffixes_only_the_guesses(
6661    ) {
6662        // Alphabetically Guess.cs sorts BEFORE Precise.cs, so a single
6663        // by-location sort over the union would interleave them. The split is
6664        // what puts the facts first, not the sort.
6665        let defs = vec![widget_def()];
6666        let edges = vec![
6667            uses_member(
6668                "Consumers/Precise.cs",
6669                10,
6670                "App.Core.Widget",
6671                "Core/Widget.cs",
6672            ),
6673            heuristic_uses_member("Consumers/Guess.cs", 7, "App.Core.Widget", "Core/Widget.cs"),
6674        ];
6675        let root = stage4_root(&defs, &edges, "stage4-order");
6676        let g = make_graph(defs, edges);
6677        let index = load_graph_index(&g, &root);
6678        let model = match build_refs_model(
6679            &index,
6680            "Widget",
6681            true,
6682            DEFAULT_CAP,
6683            INBOUND_CAP,
6684            OUTBOUND_CAP,
6685            false,
6686        ) {
6687            RefsResult::Resolved(m) => m,
6688            other => panic!("expected a resolved model, got {other:?}"),
6689        };
6690
6691        let out = crate::render::render_refs_text(&model);
6692        let lines: Vec<&str> = out.lines().collect();
6693        let start = lines
6694            .iter()
6695            .position(|l| *l == "  uses-member (2):")
6696            .expect("a uses-member block counting both rows");
6697        assert_eq!(
6698            &lines[start + 1..start + 3],
6699            [
6700                "    Consumers/Precise.cs:10  uses-member",
6701                "    Consumers/Guess.cs:7  uses-member (heuristic)"
6702            ]
6703        );
6704
6705        // --compact marks the same row with the one-character form.
6706        let compact = crate::render::render_refs_compact(&model);
6707        assert!(
6708            compact.contains(
6709                "in:uses-member (2):\n  Consumers/Precise.cs:10\n  Consumers/Guess.cs:7h"
6710            ),
6711            "{compact}"
6712        );
6713    }
6714
6715    #[test]
6716    fn stage4_cli_refs_precise_rows_filling_the_cap_leave_no_room_for_heuristic_rows() {
6717        let defs = vec![widget_def()];
6718        let mut edges: Vec<graph::Edge> = (0..31)
6719            .map(|i| {
6720                uses_member(
6721                    &format!("Consumers/C{i:02}.cs"),
6722                    4,
6723                    "App.Core.Widget",
6724                    "Core/Widget.cs",
6725                )
6726            })
6727            .collect();
6728        edges.push(heuristic_uses_member(
6729            "Consumers/Zzz.cs",
6730            9,
6731            "App.Core.Widget",
6732            "Core/Widget.cs",
6733        ));
6734        let root = stage4_root(&defs, &edges, "stage4-cap");
6735        let g = make_graph(defs, edges);
6736        let index = load_graph_index(&g, &root);
6737        let model = match build_refs_model(
6738            &index,
6739            "Widget",
6740            true,
6741            DEFAULT_CAP,
6742            INBOUND_CAP,
6743            OUTBOUND_CAP,
6744            false,
6745        ) {
6746            RefsResult::Resolved(m) => m,
6747            other => panic!("expected a resolved model, got {other:?}"),
6748        };
6749
6750        let out = crate::render::render_refs_text(&model);
6751        assert!(
6752            out.contains("  uses-member (32, 2 dropped):"),
6753            "the cap is shared: 32 rows, 30 shown, 2 dropped\n{out}"
6754        );
6755        assert!(
6756            out.contains("\n  +2 more\n"),
6757            "one trailer carries the exact count the call did not return\n{out}"
6758        );
6759        assert!(
6760            !out.contains("(heuristic)"),
6761            "precise rows have priority -- a full cap shows zero guesses"
6762        );
6763        assert_eq!(
6764            out.lines()
6765                .filter(|l| l.starts_with("    Consumers/"))
6766                .count(),
6767            30
6768        );
6769    }
6770
6771    fn stage4_impact_defs() -> Vec<graph::Def> {
6772        vec![
6773            widget_def(),
6774            def(
6775                "App.Direct.Direct",
6776                "Direct",
6777                "App.Direct",
6778                "class",
6779                "Consumers/Direct.cs",
6780                3,
6781            ),
6782            def(
6783                "App.Guessed.Guessed",
6784                "Guessed",
6785                "App.Guessed",
6786                "class",
6787                "Consumers/Guessed.cs",
6788                3,
6789            ),
6790        ]
6791    }
6792
6793    #[test]
6794    fn stage4_cli_impact_declares_heuristic_reached_files_beside_the_affected_count_only_when_there_are_some(
6795    ) {
6796        let precise = uses_type(
6797            "Consumers/Direct.cs",
6798            4,
6799            "App.Core.Widget",
6800            "Core/Widget.cs",
6801        );
6802
6803        let defs = stage4_impact_defs();
6804        let edges = vec![
6805            precise.clone(),
6806            heuristic_uses_member(
6807                "Consumers/Guessed.cs",
6808                8,
6809                "App.Core.Widget",
6810                "Core/Widget.cs",
6811            ),
6812        ];
6813        let root = stage4_root(&defs, &edges, "stage4-impact-with");
6814        let g = make_graph(defs, edges);
6815        let index = load_graph_index(&g, &root);
6816        let model = match build_impact_model(
6817            &index,
6818            "Core/Widget.cs",
6819            DEFAULT_HOPS,
6820            DEFAULT_CAP,
6821            true,
6822            DEFAULT_IFACE_MAX_FANIN,
6823            DEFAULT_HUB_MAX_INDEGREE,
6824        ) {
6825            ImpactResult::Resolved(m) => m,
6826            other => panic!("expected a resolved model, got {other:?}"),
6827        };
6828        let out = crate::render::render_impact_text("Core/Widget.cs", &model);
6829        assert!(
6830            out.contains("affected files: 1 (+1 heuristic)  shown: 2  dropped: 0"),
6831            "{out}"
6832        );
6833        let lines: Vec<&str> = out.lines().collect();
6834        let header = lines
6835            .iter()
6836            .position(|l| *l == "file  hops  via  top-symbols")
6837            .expect("row header present");
6838        assert_eq!(
6839            &lines[header + 1..header + 3],
6840            [
6841                "Consumers/Direct.cs  1  1  Widget",
6842                "Consumers/Guessed.cs  1  1  Widget (heuristic)"
6843            ],
6844            "heuristic-reached files are listed after every precise one, never ranked among them"
6845        );
6846        assert!(
6847            crate::render::render_impact_compact("Core/Widget.cs", &model)
6848                .contains("Consumers/Guessed.cs via=1h"),
6849            "compact reports the guess count, never `via=0`"
6850        );
6851
6852        // The same query on a graph with no heuristic edges must render the
6853        // count line byte-for-byte as it did before this stage -- no empty
6854        // parenthetical anywhere.
6855        let defs = stage4_impact_defs();
6856        let edges = vec![precise];
6857        let root = stage4_root(&defs, &edges, "stage4-impact-without");
6858        let g = make_graph(defs, edges);
6859        let index = load_graph_index(&g, &root);
6860        let model = match build_impact_model(
6861            &index,
6862            "Core/Widget.cs",
6863            DEFAULT_HOPS,
6864            DEFAULT_CAP,
6865            true,
6866            DEFAULT_IFACE_MAX_FANIN,
6867            DEFAULT_HUB_MAX_INDEGREE,
6868        ) {
6869            ImpactResult::Resolved(m) => m,
6870            other => panic!("expected a resolved model, got {other:?}"),
6871        };
6872        let out = crate::render::render_impact_text("Core/Widget.cs", &model);
6873        assert!(
6874            out.contains("affected files: 1  shown: 1  dropped: 0"),
6875            "{out}"
6876        );
6877        assert!(!out.contains("heuristic"), "{out}");
6878        assert!(
6879            !crate::render::render_impact_compact("Core/Widget.cs", &model).contains("heuristic")
6880        );
6881    }
6882
6883    #[test]
6884    fn stage4_cli_impact_stops_at_a_heuristic_edge_instead_of_walking_through_it_to_a_second_hop() {
6885        // Core/Widget.cs <- Mid/Middle.cs <- Far/Far.cs. The first link is the
6886        // one under test; the second is always precise, so whether Far/Far.cs
6887        // shows up depends purely on whether the walk was allowed to continue
6888        // through link 1.
6889        let impact_out = |heuristic: bool, label: &str| {
6890            let defs = vec![
6891                widget_def(),
6892                def(
6893                    "App.Mid.Middle",
6894                    "Middle",
6895                    "App.Mid",
6896                    "class",
6897                    "Mid/Middle.cs",
6898                    3,
6899                ),
6900                def("App.Far.Far", "Far", "App.Far", "class", "Far/Far.cs", 3),
6901            ];
6902            let first = if heuristic {
6903                heuristic_uses_member("Mid/Middle.cs", 8, "App.Core.Widget", "Core/Widget.cs")
6904            } else {
6905                uses_member("Mid/Middle.cs", 8, "App.Core.Widget", "Core/Widget.cs")
6906            };
6907            let edges = vec![
6908                first,
6909                uses_type("Far/Far.cs", 4, "App.Mid.Middle", "Mid/Middle.cs"),
6910            ];
6911            let root = stage4_root(&defs, &edges, label);
6912            let g = make_graph(defs, edges);
6913            let index = load_graph_index(&g, &root);
6914            let model = match build_impact_model(
6915                &index,
6916                "Core/Widget.cs",
6917                2,
6918                DEFAULT_CAP,
6919                true,
6920                DEFAULT_IFACE_MAX_FANIN,
6921                DEFAULT_HUB_MAX_INDEGREE,
6922            ) {
6923                ImpactResult::Resolved(m) => m,
6924                other => panic!("expected a resolved model, got {other:?}"),
6925            };
6926            crate::render::render_impact_text("Core/Widget.cs", &model)
6927        };
6928
6929        let control = impact_out(false, "stage4-walk-control");
6930        assert!(
6931            control.contains("Far/Far.cs"),
6932            "control: with a precise first link the walk reaches the second hop\n{control}"
6933        );
6934        assert!(control.contains("affected files: 2  "), "{control}");
6935
6936        let guessed = impact_out(true, "stage4-walk-guess");
6937        assert!(
6938            guessed.contains("Mid/Middle.cs  1  1  Widget (heuristic)"),
6939            "the guessed file itself is still reported\n{guessed}"
6940        );
6941        assert!(
6942            !guessed.contains("Far/Far.cs"),
6943            "a guess may reach a file and must never become the premise of the next hop -- compounding guesses is how blast radius turns into fiction\n{guessed}"
6944        );
6945        assert!(
6946            guessed.contains("affected files: 0 (+1 heuristic)  shown: 1  dropped: 0"),
6947            "{guessed}"
6948        );
6949    }
6950
6951    /// The heuristic adjacency really is SEPARATE: a graph whose only
6952    /// uses-type edge is tagged leaves the precise inbound table empty, so no
6953    /// consumer that never asked for guesses can see one.
6954    #[test]
6955    fn stage4_heuristic_edges_never_enter_the_precise_adjacency() {
6956        let defs = vec![widget_def()];
6957        let edges = vec![heuristic_uses_type(
6958            "Consumers/Guess.cs",
6959            7,
6960            "App.Core.Widget",
6961            "Core/Widget.cs",
6962        )];
6963        let root = stage4_root(&defs, &edges, "stage4-adjacency");
6964        let g = make_graph(defs, edges);
6965        let index = load_graph_index(&g, &root);
6966        assert!(
6967            index.inbound.get("App.Core.Widget").is_none(),
6968            "the precise inbound table never sees a tagged edge"
6969        );
6970        assert_eq!(
6971            index
6972                .heuristic_inbound
6973                .get("App.Core.Widget")
6974                .map(|e| e.uses_type.len()),
6975            Some(1),
6976            "and the heuristic one holds it, keyed by the same def id"
6977        );
6978        assert!(index.outbound_by_file.get("Consumers/Guess.cs").is_none());
6979        assert_eq!(
6980            index
6981                .heuristic_outbound_by_file
6982                .get("Consumers/Guess.cs")
6983                .map(|e| e.uses_type.len()),
6984            Some(1)
6985        );
6986    }
6987
6988    // ========================================================================
6989    // Test-coverage: test_defs_by_file, build_tests_model, impact tests_affected.
6990    // ========================================================================
6991
6992    /// `def()` carrying a test-method list -- what makes its file a TEST file.
6993    fn test_def(
6994        id: &str,
6995        name: &str,
6996        file: &str,
6997        line: usize,
6998        test_methods: &[&str],
6999    ) -> graph::Def {
7000        graph::Def {
7001            test_methods: test_methods.iter().map(|s| s.to_string()).collect(),
7002            ..def(id, name, "App.Orders.Tests", "class", file, line)
7003        }
7004    }
7005
7006    const TESTS_MANIFEST_FILES: &[&str] = &[
7007        "src/OrderService.cs",
7008        "src/Untested.cs",
7009        "tests/OrderServiceTests.cs",
7010        "tests/Fakes.cs",
7011        "tests/Partial.cs",
7012        "tests/Partial.Extra.cs",
7013    ];
7014
7015    /// One production type referenced twice from a real test file, once from a
7016    /// non-test neighbour, and once by a GUESS from a partial test class's
7017    /// second declaring file -- the four cases the model has to tell apart.
7018    fn tests_fixture_graph() -> graph::Graph {
7019        let mut partial = test_def(
7020            "App.Orders.Tests.PartialTests",
7021            "PartialTests",
7022            "tests/Partial.cs",
7023            3,
7024            &["Scales"],
7025        );
7026        partial.also_in = vec![graph::AlsoIn {
7027            file: "tests/Partial.Extra.cs".into(),
7028            line: 3,
7029        }];
7030        make_graph(
7031            vec![
7032                def(
7033                    "App.Orders.OrderService",
7034                    "OrderService",
7035                    "App.Orders",
7036                    "class",
7037                    "src/OrderService.cs",
7038                    3,
7039                ),
7040                def(
7041                    "App.Orders.Untested",
7042                    "Untested",
7043                    "App.Orders",
7044                    "class",
7045                    "src/Untested.cs",
7046                    3,
7047                ),
7048                test_def(
7049                    "App.Orders.Tests.OrderServiceTests",
7050                    "OrderServiceTests",
7051                    "tests/OrderServiceTests.cs",
7052                    5,
7053                    &["Totals"],
7054                ),
7055                def(
7056                    "App.Orders.Tests.Fakes",
7057                    "Fakes",
7058                    "App.Orders.Tests",
7059                    "class",
7060                    "tests/Fakes.cs",
7061                    3,
7062                ),
7063                partial,
7064            ],
7065            vec![
7066                uses_type(
7067                    "tests/OrderServiceTests.cs",
7068                    11,
7069                    "App.Orders.OrderService",
7070                    "src/OrderService.cs",
7071                ),
7072                uses_type(
7073                    "tests/OrderServiceTests.cs",
7074                    10,
7075                    "App.Orders.OrderService",
7076                    "src/OrderService.cs",
7077                ),
7078                uses_type(
7079                    "tests/OrderServiceTests.cs",
7080                    10,
7081                    "App.Orders.OrderService",
7082                    "src/OrderService.cs",
7083                ),
7084                uses_type(
7085                    "tests/Fakes.cs",
7086                    7,
7087                    "App.Orders.OrderService",
7088                    "src/OrderService.cs",
7089                ),
7090                heuristic_uses_member(
7091                    "tests/Partial.Extra.cs",
7092                    9,
7093                    "App.Orders.OrderService",
7094                    "src/OrderService.cs",
7095                ),
7096            ],
7097        )
7098    }
7099
7100    fn tests_fixture_root() -> PathBuf {
7101        let root = temp_repo_root("tests-model");
7102        write_manifest_fixture(&root, TESTS_MANIFEST_FILES);
7103        root
7104    }
7105
7106    fn resolved_tests(model: TestsResult) -> TestsModel {
7107        match model {
7108            TestsResult::Resolved(m) => m,
7109            other => panic!("expected a resolved tests model, got {other:?}"),
7110        }
7111    }
7112
7113    #[test]
7114    fn test_defs_by_file_registers_every_declaring_site_and_never_a_file_without_a_test_def() {
7115        let g = tests_fixture_graph();
7116        let index = load_graph_index(&g, &tests_fixture_root());
7117        assert!(index
7118            .test_defs_by_file
7119            .contains_key("tests/OrderServiceTests.cs"));
7120        assert!(
7121            index
7122                .test_defs_by_file
7123                .contains_key("tests/Partial.Extra.cs"),
7124            "a partial test class registers its second declaring file too"
7125        );
7126        assert!(
7127            !index.test_defs_by_file.contains_key("tests/Fakes.cs"),
7128            "a file is a test file only because of the attribute"
7129        );
7130        assert!(!index.test_defs_by_file.contains_key("src/OrderService.cs"));
7131    }
7132
7133    #[test]
7134    fn build_tests_model_names_the_test_file_its_test_defs_and_every_referencing_line() {
7135        let g = tests_fixture_graph();
7136        let index = load_graph_index(&g, &tests_fixture_root());
7137        let m = resolved_tests(build_tests_model(&index, "OrderService"));
7138
7139        assert_eq!(m.symbol, "App.Orders.OrderService");
7140        assert_eq!(m.def_files, vec!["src/OrderService.cs".to_string()]);
7141        assert_eq!(
7142            m.test_file_count, 1,
7143            "the non-test neighbour is not a test file"
7144        );
7145        assert_eq!(
7146            m.ref_count, 3,
7147            "lines keep duplicates -- refCount is the reference count, not the distinct-line count"
7148        );
7149        assert_eq!(m.rows.len(), 2, "one precise row, then the heuristic one");
7150
7151        let precise = &m.rows[0];
7152        assert_eq!(precise.file, "tests/OrderServiceTests.cs");
7153        assert_eq!(
7154            precise.test_defs,
7155            vec!["App.Orders.Tests.OrderServiceTests".to_string()]
7156        );
7157        assert_eq!(
7158            precise.lines,
7159            vec![10, 10, 11],
7160            "ascending, duplicates kept"
7161        );
7162        assert_eq!(precise.ref_count, 3);
7163        assert!(!precise.heuristic);
7164    }
7165
7166    #[test]
7167    fn build_tests_model_puts_heuristic_rows_after_every_precise_one_and_counts_them_separately() {
7168        let g = tests_fixture_graph();
7169        let index = load_graph_index(&g, &tests_fixture_root());
7170        let m = resolved_tests(build_tests_model(&index, "OrderService"));
7171
7172        let guessed = &m.rows[1];
7173        assert!(
7174            guessed.heuristic,
7175            "a guess never sits inside the list of facts"
7176        );
7177        assert_eq!(guessed.file, "tests/Partial.Extra.cs");
7178        assert_eq!(
7179            guessed.test_defs,
7180            vec!["App.Orders.Tests.PartialTests".to_string()]
7181        );
7182        assert_eq!(guessed.lines, vec![9]);
7183        assert_eq!(m.heuristic_file_count, 1);
7184        assert_eq!(m.heuristic_ref_count, 1);
7185        assert_eq!(m.test_file_count, 1, "files= and refs= stay PRECISE-only");
7186    }
7187
7188    #[test]
7189    fn build_tests_model_on_a_symbol_no_test_references_resolves_with_no_rows() {
7190        let g = tests_fixture_graph();
7191        let index = load_graph_index(&g, &tests_fixture_root());
7192        let m = resolved_tests(build_tests_model(&index, "Untested"));
7193        assert_eq!(m.symbol, "App.Orders.Untested");
7194        assert!(m.rows.is_empty());
7195        assert_eq!(
7196            (
7197                m.test_file_count,
7198                m.ref_count,
7199                m.heuristic_file_count,
7200                m.heuristic_ref_count
7201            ),
7202            (0, 0, 0, 0)
7203        );
7204    }
7205
7206    #[test]
7207    fn build_tests_model_uses_the_same_resolve_symbol_ladder_refs_does() {
7208        let g = tests_fixture_graph();
7209        let index = load_graph_index(&g, &tests_fixture_root());
7210        assert_eq!(
7211            build_tests_model(&index, "NoSuchSymbol"),
7212            TestsResult::NotFound
7213        );
7214        assert_eq!(
7215            resolved_tests(build_tests_model(&index, "orderservice")).symbol,
7216            "App.Orders.OrderService",
7217            "case-insensitive unique name is the ladder's last rung, same as refs"
7218        );
7219
7220        let ambiguous_graph = base_fixture_graph();
7221        let ambiguous_index = load_graph_index(&ambiguous_graph, &base_fixture_root());
7222        assert_eq!(
7223            build_tests_model(&ambiguous_index, "Config"),
7224            TestsResult::Ambiguous(vec![
7225                "App.One.Config".to_string(),
7226                "App.Two.Config".to_string()
7227            ])
7228        );
7229    }
7230
7231    #[test]
7232    fn build_impact_model_counts_precisely_affected_test_files_only() {
7233        let g = tests_fixture_graph();
7234        let index = load_graph_index(&g, &tests_fixture_root());
7235
7236        let ImpactResult::Resolved(covered) = build_impact_model(
7237            &index,
7238            "src/OrderService.cs",
7239            2,
7240            DEFAULT_CAP,
7241            true,
7242            DEFAULT_IFACE_MAX_FANIN,
7243            DEFAULT_HUB_MAX_INDEGREE,
7244        ) else {
7245            panic!("file seed resolves");
7246        };
7247        assert_eq!(
7248            covered.tests_affected, 1,
7249            "the guessed test file is not coverage; the non-test neighbour is not a test file"
7250        );
7251
7252        let ImpactResult::Resolved(untouched) = build_impact_model(
7253            &index,
7254            "src/Untested.cs",
7255            2,
7256            DEFAULT_CAP,
7257            true,
7258            DEFAULT_IFACE_MAX_FANIN,
7259            DEFAULT_HUB_MAX_INDEGREE,
7260        ) else {
7261            panic!("file seed resolves");
7262        };
7263        assert_eq!(
7264            untouched.tests_affected, 0,
7265            "zero is the interesting answer -- a blast radius reaching no test file at all"
7266        );
7267    }
7268    // --- a bare member name, resolved by verifying edges at their line ---
7269
7270    fn name_row(name: &str, kind: &str, file: &str, line: usize, owner: &str) -> graph::GraphName {
7271        graph::GraphName {
7272            name: name.into(),
7273            kind: kind.into(),
7274            file: file.into(),
7275            line,
7276            owner: owner.into(),
7277        }
7278    }
7279
7280    /// `Ledger` carries three inbound member edges (two of them one line
7281    /// apart), and only one line names each of `Post`/`PostEx`. `Approve` is
7282    /// declared on BOTH `Ledger` and `Journal`, each with one edge whose line
7283    /// names it -- the ambiguity case, where verification survives on more than
7284    /// one declaring type.
7285    fn member_fixture() -> (graph::Graph, PathBuf) {
7286        let mut g = make_graph(
7287            vec![
7288                def(
7289                    "App.Books.Ledger",
7290                    "Ledger",
7291                    "App.Books",
7292                    "class",
7293                    "Books/Ledger.cs",
7294                    3,
7295                ),
7296                def(
7297                    "App.Books.Journal",
7298                    "Journal",
7299                    "App.Books",
7300                    "class",
7301                    "Books/Journal.cs",
7302                    3,
7303                ),
7304                def(
7305                    "App.Books.Consumer",
7306                    "Consumer",
7307                    "App.Books",
7308                    "class",
7309                    "Books/Consumer.cs",
7310                    1,
7311                ),
7312            ],
7313            vec![
7314                uses_member(
7315                    "Books/Consumer.cs",
7316                    1,
7317                    "App.Books.Ledger",
7318                    "Books/Ledger.cs",
7319                ),
7320                uses_member(
7321                    "Books/Consumer.cs",
7322                    2,
7323                    "App.Books.Ledger",
7324                    "Books/Ledger.cs",
7325                ),
7326                uses_member(
7327                    "Books/Consumer.cs",
7328                    3,
7329                    "App.Books.Ledger",
7330                    "Books/Ledger.cs",
7331                ),
7332                uses_member(
7333                    "Books/Consumer.cs",
7334                    4,
7335                    "App.Books.Journal",
7336                    "Books/Journal.cs",
7337                ),
7338            ],
7339        );
7340        g.names = vec![
7341            name_row("Ledger", "class", "Books/Ledger.cs", 3, ""),
7342            name_row("Post", "method", "Books/Ledger.cs", 5, "App.Books.Ledger"),
7343            name_row("PostEx", "method", "Books/Ledger.cs", 7, "App.Books.Ledger"),
7344            name_row(
7345                "Reconcile",
7346                "method",
7347                "Books/Ledger.cs",
7348                9,
7349                "App.Books.Ledger",
7350            ),
7351            name_row(
7352                "Approve",
7353                "method",
7354                "Books/Ledger.cs",
7355                11,
7356                "App.Books.Ledger",
7357            ),
7358            name_row("Journal", "class", "Books/Journal.cs", 3, ""),
7359            name_row(
7360                "Approve",
7361                "method",
7362                "Books/Journal.cs",
7363                5,
7364                "App.Books.Journal",
7365            ),
7366        ];
7367        let root = temp_repo_root("bare-member");
7368        write_manifest_fixture(
7369            &root,
7370            &["Books/Ledger.cs", "Books/Journal.cs", "Books/Consumer.cs"],
7371        );
7372        fs::create_dir_all(root.join("Books")).expect("fixture dir");
7373        fs::write(
7374            root.join("Books/Consumer.cs"),
7375            "Ledger.Post(1);\nLedger.PostEx(2);\nLedger.Approve(3);\nJournal.Approve(4);\n",
7376        )
7377        .expect("fixture file");
7378        (g, root)
7379    }
7380
7381    fn member_models(index: &GraphIndex, query: &str, inbound_cap: usize) -> Vec<RefsModel> {
7382        match build_refs_model(
7383            index,
7384            query,
7385            false,
7386            DEFAULT_CAP,
7387            inbound_cap,
7388            OUTBOUND_CAP,
7389            false,
7390        ) {
7391            RefsResult::Members(models) => models,
7392            other => panic!("expected Members, got {other:?}"),
7393        }
7394    }
7395
7396    #[test]
7397    fn line_has_token_matches_whole_tokens_only() {
7398        assert!(line_has_token("Ledger.Post(1);", "Post"));
7399        assert!(line_has_token("Post", "Post"));
7400        assert!(!line_has_token("Ledger.PostEx(2);", "Post"));
7401        assert!(!line_has_token("RePost(2);", "Post"));
7402        assert!(!line_has_token("Post_x();", "Post"));
7403        assert!(!line_has_token("x1Post();", "Post"));
7404        // Every code point outside ASCII is a boundary: this reads the
7405        // neighbouring UTF-8 byte, which is never an ASCII word character.
7406        assert!(line_has_token("\u{2026}Post\u{2026}", "Post"));
7407        assert!(!line_has_token("", "Post"));
7408        assert!(!line_has_token("Post", ""));
7409    }
7410
7411    #[test]
7412    fn build_refs_model_bare_member_keeps_only_the_edges_whose_line_names_it() {
7413        let (g, root) = member_fixture();
7414        let index = load_graph_index(&g, &root);
7415        let models = member_models(&index, "PostEx", INBOUND_CAP);
7416
7417        assert_eq!(models.len(), 1);
7418        let m = &models[0];
7419        assert_eq!(m.id, "App.Books.Ledger.PostEx");
7420        assert_eq!(m.kind, "member");
7421        assert_eq!(
7422            m.sites,
7423            vec![DefSite {
7424                file: "Books/Ledger.cs".into(),
7425                line: 7
7426            }]
7427        );
7428        assert_eq!(
7429            m.inbound.uses_member.total, 1,
7430            "the type has three inbound member edges; one names PostEx"
7431        );
7432        assert_eq!(
7433            m.inbound.uses_member.rows,
7434            vec![InboundRow {
7435                file: "Books/Consumer.cs".into(),
7436                line: 2,
7437                heuristic: false,
7438                source: "Ledger.PostEx(2);".into()
7439            }]
7440        );
7441        assert_eq!(m.inbound.inherits.total, 0);
7442        assert!(
7443            m.outbound.is_none(),
7444            "a member answer never carries the outbound tables"
7445        );
7446    }
7447
7448    #[test]
7449    fn build_refs_model_bare_member_refuses_a_longer_identifier_that_starts_with_the_query() {
7450        let (g, root) = member_fixture();
7451        let index = load_graph_index(&g, &root);
7452        let models = member_models(&index, "Post", INBOUND_CAP);
7453        let ledger = models
7454            .iter()
7455            .find(|m| m.id == "App.Books.Ledger.Post")
7456            .expect("Ledger declares Post");
7457
7458        assert_eq!(
7459            ledger
7460                .inbound
7461                .uses_member
7462                .rows
7463                .iter()
7464                .map(|r| r.line)
7465                .collect::<Vec<_>>(),
7466            vec![1],
7467            "line 2 is `Ledger.PostEx(2);` -- a substring hit, not a token hit"
7468        );
7469    }
7470
7471    // `Approve` is declared on Ledger AND Journal, and both survive edge-line
7472    // verification (unlike the fixture's own `Post`, now single-owner): the
7473    // answer is the ambiguous candidate list, in name-index order, never a
7474    // `Members` block per type -- the same house rule of never guessing between
7475    // candidates that an ambiguous TYPE name already answers with.
7476    #[test]
7477    fn build_refs_model_bare_member_verified_on_several_types_answers_ambiguous_not_members() {
7478        let (g, root) = member_fixture();
7479        let index = load_graph_index(&g, &root);
7480        let model = build_refs_model(
7481            &index,
7482            "Approve",
7483            false,
7484            DEFAULT_CAP,
7485            INBOUND_CAP,
7486            OUTBOUND_CAP,
7487            false,
7488        );
7489
7490        assert_eq!(
7491            model,
7492            RefsResult::Ambiguous(vec![
7493                "App.Books.Ledger".to_string(),
7494                "App.Books.Journal".to_string()
7495            ])
7496        );
7497    }
7498
7499    #[test]
7500    fn build_refs_model_bare_member_ambiguous_across_types_answers_the_same_regardless_of_inbound_cap(
7501    ) {
7502        let (g, root) = member_fixture();
7503        let index = load_graph_index(&g, &root);
7504        let model = build_refs_model(
7505            &index,
7506            "Approve",
7507            false,
7508            DEFAULT_CAP,
7509            1,
7510            OUTBOUND_CAP,
7511            false,
7512        );
7513
7514        assert_eq!(
7515            model,
7516            RefsResult::Ambiguous(vec![
7517                "App.Books.Ledger".to_string(),
7518                "App.Books.Journal".to_string()
7519            ])
7520        );
7521    }
7522
7523    #[test]
7524    fn build_refs_model_bare_member_with_no_verified_edge_stays_not_found() {
7525        let (g, root) = member_fixture();
7526        let index = load_graph_index(&g, &root);
7527
7528        assert_eq!(
7529            build_refs_model(
7530                &index,
7531                "Reconcile",
7532                false,
7533                DEFAULT_CAP,
7534                INBOUND_CAP,
7535                OUTBOUND_CAP,
7536                false
7537            ),
7538            RefsResult::NotFound
7539        );
7540        assert_eq!(
7541            build_refs_model(
7542                &index,
7543                "NoSuchMemberAnywhere",
7544                false,
7545                DEFAULT_CAP,
7546                INBOUND_CAP,
7547                OUTBOUND_CAP,
7548                false
7549            ),
7550            RefsResult::NotFound
7551        );
7552    }
7553
7554    #[test]
7555    fn build_refs_model_prefers_a_type_over_a_member_of_the_same_name() {
7556        let (g, root) = member_fixture();
7557        let index = load_graph_index(&g, &root);
7558
7559        let RefsResult::Resolved(model) = build_refs_model(
7560            &index,
7561            "Ledger",
7562            false,
7563            DEFAULT_CAP,
7564            INBOUND_CAP,
7565            OUTBOUND_CAP,
7566            false,
7567        ) else {
7568            panic!("Ledger is a type and must resolve as one");
7569        };
7570        assert_eq!(model.id, "App.Books.Ledger");
7571    }
7572
7573    #[test]
7574    fn read_excludes_references_inside_the_target_declaration_span() {
7575        let root = temp_repo_root("read-self-inbound");
7576        fs::create_dir_all(root.join("Core")).unwrap();
7577        fs::create_dir_all(root.join("Consumers")).unwrap();
7578        fs::write(
7579            root.join("Core/Widget.cs"),
7580            "namespace App;\npublic class Widget\n{\n    Widget Again() => new Widget();\n}\n",
7581        )
7582        .unwrap();
7583        fs::write(
7584            root.join("Consumers/Reader.cs"),
7585            "namespace App;\npublic class Reader { Widget Value; }\n",
7586        )
7587        .unwrap();
7588        write_manifest_fixture(&root, &["Core/Widget.cs", "Consumers/Reader.cs"]);
7589        let mut target = def("App.Widget", "Widget", "App", "class", "Core/Widget.cs", 2);
7590        target.end_line = 5;
7591        let graph = make_graph(
7592            vec![target],
7593            vec![
7594                uses_type("Core/Widget.cs", 4, "App.Widget", "Core/Widget.cs"),
7595                uses_type("Consumers/Reader.cs", 2, "App.Widget", "Core/Widget.cs"),
7596            ],
7597        );
7598        let index = load_graph_index(&graph, &root);
7599        let ReadResult::Resolved(model) = build_read_model(&index, "Widget") else {
7600            panic!("Widget must resolve")
7601        };
7602        assert_eq!(model.refs.inbound.uses_type.total, 1);
7603        assert_eq!(
7604            model.refs.inbound.uses_type.rows[0].file,
7605            "Consumers/Reader.cs"
7606        );
7607    }
7608
7609    // --- the per-file first-declaration line find's manifest-pool block reads ---
7610
7611    fn graph_name(name: &str, kind: &str, file: &str, line: usize) -> graph::GraphName {
7612        graph::GraphName {
7613            name: name.to_string(),
7614            kind: kind.to_string(),
7615            file: file.to_string(),
7616            line,
7617            owner: String::new(),
7618        }
7619    }
7620
7621    #[test]
7622    fn first_decl_line_by_file_keeps_the_minimum_line_per_file_across_the_whole_name_index_not_just_the_last_one_seen(
7623    ) {
7624        let mut graph = make_graph(vec![], vec![]);
7625        graph.names = vec![
7626            graph_name("Widget", "class", "a.cs", 5),
7627            graph_name("Render", "method", "a.cs", 12),
7628            graph_name("Widget", "class", "a.cs", 1),
7629            graph_name("Other", "class", "b.cs", 8),
7630        ];
7631        let mut rows: Vec<(String, usize)> = first_decl_line_by_file(&graph).into_iter().collect();
7632        rows.sort();
7633        assert_eq!(rows, vec![("a.cs".to_string(), 1), ("b.cs".to_string(), 8)]);
7634    }
7635
7636    #[test]
7637    fn first_decl_line_by_file_on_a_graph_with_no_names_index_is_empty() {
7638        assert!(first_decl_line_by_file(&make_graph(vec![], vec![])).is_empty());
7639    }
7640}