Skip to main content

_diffctx/edges/
base.rs

1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use rustc_hash::{FxHashMap, FxHashSet};
5
6use crate::config::edge_weights::SEMANTIC_DISCOVERY;
7use crate::config::extensions::CODE_EXTENSIONS;
8use crate::types::{Fragment, FragmentId};
9
10use super::EdgeDict;
11
12pub trait EdgeBuilder: Send + Sync {
13    fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict;
14
15    fn discover_related_files(
16        &self,
17        _changed: &[PathBuf],
18        _candidates: &[PathBuf],
19        _repo_root: Option<&Path>,
20        _file_cache: Option<&FxHashMap<PathBuf, String>>,
21    ) -> Vec<PathBuf> {
22        vec![]
23    }
24
25    fn category_label(&self) -> Option<&str> {
26        None
27    }
28
29    fn is_expensive(&self) -> bool {
30        false
31    }
32
33    /// A coverage-of-last-resort builder: its edges only count where the
34    /// dedicated builders produced nothing. The dual-coverage attempt this
35    /// gate encodes was measured as a 100% noise regression (#131) — tags
36    /// edges duplicating real semantic edges add no reach, only mass.
37    fn is_fallback(&self) -> bool {
38        false
39    }
40}
41
42static INDEX_FILE_STEMS: Lazy<FxHashSet<&str>> =
43    Lazy::new(|| ["__init__", "index", "mod"].iter().copied().collect());
44
45fn strip_source_prefix(parts: &[&str]) -> Vec<String> {
46    for (i, part) in parts.iter().enumerate() {
47        if *part == "src" || *part == "lib" || *part == "packages" {
48            return parts[i + 1..].iter().map(|s| s.to_string()).collect();
49        }
50    }
51    parts.iter().map(|s| s.to_string()).collect()
52}
53
54fn strip_file_extension(stem: &str) -> &str {
55    for ext in CODE_EXTENSIONS.iter() {
56        if let Some(stripped) = stem.strip_suffix(ext) {
57            return stripped;
58        }
59    }
60    stem
61}
62
63pub fn path_to_module(path: &Path, repo_root: Option<&Path>) -> String {
64    let effective = if let Some(root) = repo_root {
65        if path.is_absolute() {
66            path.strip_prefix(root).unwrap_or(path)
67        } else {
68            path
69        }
70    } else {
71        path
72    };
73
74    let parts_raw: Vec<&str> = effective.iter().filter_map(|c| c.to_str()).collect();
75    let mut parts = strip_source_prefix(&parts_raw);
76
77    if let Some(last) = parts.last_mut() {
78        let stripped = strip_file_extension(last).to_string();
79        *last = stripped;
80    }
81
82    if let Some(last) = parts.last() {
83        if INDEX_FILE_STEMS.contains(last.as_str()) {
84            parts.pop();
85        }
86    }
87
88    parts.join(".")
89}
90
91pub struct FragmentIndex {
92    pub by_name: FxHashMap<String, Vec<FragmentId>>,
93    pub by_path: FxHashMap<String, Vec<FragmentId>>,
94    /// Lowercased path table plus a component→paths posting list, so a path
95    /// reference resolves by looking up its last component instead of scanning
96    /// every indexed path. Every accepted alignment (equal, suffix, prefix,
97    /// interior) requires each reference component to appear as a whole path
98    /// component, so the last component is a complete candidate filter. Each
99    /// entry carries the file's representative fragment: a path reference is a
100    /// file-level relation and endorses the file, not each sibling (see
101    /// `file_representatives` on the accepted reachability trade, #208).
102    lower_paths: Vec<(String, FragmentId)>,
103    component_to_paths: FxHashMap<String, Vec<u32>>,
104}
105
106impl FragmentIndex {
107    pub fn new(fragments: &[Fragment], repo_root: Option<&Path>) -> Self {
108        let mut by_name: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
109        let mut by_path: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
110
111        for f in fragments {
112            let path = Path::new(f.path());
113            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
114                by_name
115                    .entry(name.to_lowercase())
116                    .or_default()
117                    .push(f.id.clone());
118            }
119            by_path
120                .entry(f.path().to_string())
121                .or_default()
122                .push(f.id.clone());
123
124            if let Some(root) = repo_root {
125                if let Ok(rel) = Path::new(f.path()).strip_prefix(root) {
126                    let rel_str = rel.to_string_lossy().to_string();
127                    by_path
128                        .entry(rel_str.clone())
129                        .or_default()
130                        .push(f.id.clone());
131                    let posix = rel_str.replace('\\', "/");
132                    if posix != rel_str {
133                        by_path.entry(posix).or_default().push(f.id.clone());
134                    }
135                }
136            }
137        }
138
139        // One entry per FILE, keyed on the repo-relative posix path (the form
140        // path references are written in). Indexing every `by_path` variant put
141        // the same file under 2-3 keys, so `MAX_FILES_PER_PATH_REF` counted
142        // duplicate keys and silently halved (#207) — and the absolute keys'
143        // host directory components (`/home/runner/work/...`) matched
144        // references they had nothing to do with.
145        let reps = file_representatives(fragments);
146        let mut lower_paths: Vec<(String, FragmentId)> = Vec::with_capacity(reps.len());
147        let mut component_to_paths: FxHashMap<String, Vec<u32>> = FxHashMap::default();
148        let mut indexed_files: FxHashSet<&str> = FxHashSet::default();
149        for f in fragments {
150            if !indexed_files.insert(f.path()) {
151                continue;
152            }
153            let Some(rep) = reps.get(f.path()) else {
154                continue;
155            };
156            let matchable = repo_root
157                .and_then(|root| Path::new(f.path()).strip_prefix(root).ok())
158                .map(|rel| rel.to_string_lossy().replace('\\', "/"))
159                .unwrap_or_else(|| f.path().replace('\\', "/"));
160            let idx = lower_paths.len() as u32;
161            let lower = matchable.to_lowercase();
162            for comp in lower.split('/').filter(|c| !c.is_empty()) {
163                let posting = component_to_paths.entry(comp.to_string()).or_default();
164                if posting.last() != Some(&idx) {
165                    posting.push(idx);
166                }
167            }
168            lower_paths.push((lower, rep.clone()));
169        }
170
171        Self {
172            by_name,
173            by_path,
174            lower_paths,
175            component_to_paths,
176        }
177    }
178}
179
180/// One representative fragment per file — the largest by token count, ties
181/// resolved by first-seen order. This is the semantics `SiblingEdgeBuilder`
182/// always used for its file-level edges; file-level *relations* (an include,
183/// a header/impl pair, a path reference) link representatives rather than
184/// every-fragment-to-every-fragment, because the relation names the file. A
185/// change endorsing a file diffusely must not outweigh a call edge naming one
186/// symbol — the fragment-pair encoding gave both the same weight, which is
187/// simultaneously the quadratic edge blow-up (envoy: 22M c-family edges) and
188/// the file-level noise mechanism of #65. The file's other fragments are
189/// reachable only through nesting and naming edges — the intra-file star is
190/// OFF by default and measured net-negative on the corpus (#208) — an
191/// accepted trade: a file-level relation endorses the file, not each of its
192/// siblings.
193pub fn file_representatives(fragments: &[Fragment]) -> FxHashMap<String, FragmentId> {
194    let mut file_to_rep: FxHashMap<String, FragmentId> = FxHashMap::default();
195    let mut file_to_token_count: FxHashMap<String, u32> = FxHashMap::default();
196
197    for f in fragments {
198        let path = f.path().to_string();
199        let existing_count = file_to_token_count.get(&path).copied().unwrap_or(0);
200        if !file_to_rep.contains_key(&path) || f.token_count > existing_count {
201            file_to_rep.insert(path.clone(), f.id.clone());
202            file_to_token_count.insert(path, f.token_count);
203        }
204    }
205
206    file_to_rep
207}
208
209pub fn add_edge(
210    edges: &mut EdgeDict,
211    src: &FragmentId,
212    dst: &FragmentId,
213    weight: f64,
214    reverse_factor: f64,
215) {
216    let key_fwd = (src.clone(), dst.clone());
217    let existing_fwd = edges.get(&key_fwd).copied().unwrap_or(0.0);
218    if weight > existing_fwd {
219        edges.insert(key_fwd, weight);
220    }
221    let rev_w = weight * reverse_factor;
222    let key_rev = (dst.clone(), src.clone());
223    let existing_rev = edges.get(&key_rev).copied().unwrap_or(0.0);
224    if rev_w > existing_rev {
225        edges.insert(key_rev, rev_w);
226    }
227}
228
229pub fn add_edge_unidirectional(
230    edges: &mut EdgeDict,
231    src: &FragmentId,
232    dst: &FragmentId,
233    weight: f64,
234) {
235    let key = (src.clone(), dst.clone());
236    let existing = edges.get(&key).copied().unwrap_or(0.0);
237    if weight > existing {
238        edges.insert(key, weight);
239    }
240}
241
242pub fn add_edges_from_ids(
243    edges: &mut EdgeDict,
244    src: &FragmentId,
245    targets: &[FragmentId],
246    weight: f64,
247    reverse_factor: f64,
248) {
249    for target in targets {
250        if target != src {
251            add_edge(edges, src, target, weight, reverse_factor);
252        }
253    }
254}
255
256pub fn link_by_name(
257    src_id: &FragmentId,
258    name: &str,
259    idx: &FragmentIndex,
260    edges: &mut EdgeDict,
261    weight: f64,
262    reverse_factor: f64,
263) {
264    let target = name.split('/').next_back().unwrap_or(name).to_lowercase();
265    if let Some(frag_ids) = idx.by_name.get(&target) {
266        for fid in frag_ids {
267            if fid != src_id {
268                add_edge(edges, src_id, fid, weight, reverse_factor);
269                return;
270            }
271        }
272    }
273    link_by_path_match(src_id, name, idx, edges, weight, reverse_factor);
274}
275
276/// Links `src_id` to fragments of files the reference plausibly names.
277///
278/// A match must be component-aligned: the reference equals the path, a full
279/// suffix of it, or a full prefix (a bazel package label names a directory).
280/// The previous `contains` accepted a match *anywhere* in the path, so a short
281/// generic reference — bazel deps and ansible roles are full of `config`,
282/// `test`, `common` — matched thousands of paths and linked every fragment of
283/// each, which is one of the two mechanisms behind envoy-class instances
284/// hanging the graph build (50% of dcbench).
285pub fn link_by_path_match(
286    src_id: &FragmentId,
287    ref_str: &str,
288    idx: &FragmentIndex,
289    edges: &mut EdgeDict,
290    weight: f64,
291    reverse_factor: f64,
292) {
293    let ref_norm = ref_str.trim_matches('/');
294    if ref_norm.is_empty() {
295        return;
296    }
297    // Case folding subsumes the exact comparison, so matching is done entirely
298    // in lowercase. The last reference component narrows the scan: it must be
299    // a whole component of any aligned path, so only its posting list is
300    // visited instead of every indexed path — the full scan was where an
301    // envoy-scale bazel/ansible pass burned its 240s (~1e5 refs x ~1e4 paths,
302    // an allocation per probe).
303    let ref_lower = ref_norm.to_lowercase();
304    let Some(last) = ref_lower.split('/').next_back() else {
305        return;
306    };
307    let Some(posting) = idx.component_to_paths.get(last) else {
308        return;
309    };
310    let needle = format!("/{ref_lower}/");
311    let matched: Vec<&FragmentId> = posting
312        .iter()
313        .filter_map(|&pi| {
314            let (path_lower, rep) = &idx.lower_paths[pi as usize];
315            component_aligned(path_lower, &ref_lower, &needle).then_some(rep)
316        })
317        .collect();
318    // A reference resolving to more files than this names a *region*, not a
319    // dependency: a bazel label for a directory of hundreds of files says
320    // nothing about which of them relates to the change, and emitting an edge
321    // to every fragment of every one is the remaining half of the envoy hang
322    // (system time: the EdgeDict alone reached page-fault territory).
323    if matched.len() > MAX_FILES_PER_PATH_REF {
324        return;
325    }
326    for rep in matched {
327        if rep != src_id {
328            add_edge(edges, src_id, rep, weight, reverse_factor);
329        }
330    }
331}
332
333/// Same ambiguity bar as `CFamilySemanticWeights::max_files_per_name`, applied
334/// to path references.
335const MAX_FILES_PER_PATH_REF: usize = 8;
336
337fn component_aligned(path: &str, reference: &str, interior_needle: &str) -> bool {
338    if path == reference {
339        return true;
340    }
341    if let Some(rest) = path.strip_suffix(reference) {
342        if rest.ends_with('/') {
343            return true;
344        }
345    }
346    if let Some(rest) = path.strip_prefix(reference) {
347        if rest.starts_with('/') {
348            return true;
349        }
350    }
351    // A directory reference matching an interior span of the path
352    // (`roles/<name>/tasks/main.yml`) still counts, but only whole components:
353    // `config` must not match `preconfigured/`.
354    path.contains(interior_needle)
355}
356
357pub fn read_file_cached<'a>(
358    path: &Path,
359    cache: Option<&'a FxHashMap<PathBuf, String>>,
360) -> Option<String> {
361    if let Some(c) = cache {
362        if let Some(content) = c.get(path) {
363            return Some(content.clone());
364        }
365    }
366    std::fs::read_to_string(path).ok()
367}
368
369fn candidate_rel_path(candidate: &Path, repo_root: Option<&Path>) -> String {
370    if let Some(root) = repo_root {
371        if let Ok(rel) = candidate.strip_prefix(root) {
372            return rel.to_string_lossy().to_lowercase();
373        }
374    }
375    candidate
376        .file_name()
377        .map(|n| n.to_string_lossy().to_lowercase())
378        .unwrap_or_default()
379}
380
381fn matches_any_ref(candidate_name: &str, candidate_rel: &str, refs: &FxHashSet<String>) -> bool {
382    for r in refs {
383        let ref_name = r.split('/').next_back().unwrap_or(r).to_lowercase();
384        if candidate_name == ref_name {
385            return true;
386        }
387        let ref_lower = r.to_lowercase();
388        if ref_lower.len() >= SEMANTIC_DISCOVERY.min_ref_length_for_path_match {
389            if let Some(idx) = candidate_rel.find(&ref_lower) {
390                let end_idx = idx + ref_lower.len();
391                let start_ok = idx == 0
392                    || candidate_rel.as_bytes().get(idx - 1) == Some(&b'/')
393                    || candidate_rel.as_bytes().get(idx - 1) == Some(&b'\\');
394                let end_ok = end_idx == candidate_rel.len()
395                    || matches!(
396                        candidate_rel.as_bytes().get(end_idx),
397                        Some(b'/') | Some(b'\\') | Some(b'.')
398                    );
399                if start_ok && end_ok {
400                    return true;
401                }
402            }
403        }
404    }
405    false
406}
407
408pub fn discover_files_by_refs(
409    refs: &FxHashSet<String>,
410    changed_files: &[PathBuf],
411    all_candidates: &[PathBuf],
412    repo_root: Option<&Path>,
413) -> Vec<PathBuf> {
414    if refs.is_empty() {
415        return vec![];
416    }
417    let changed_set: FxHashSet<&PathBuf> = changed_files.iter().collect();
418    let mut discovered = Vec::new();
419    for candidate in all_candidates {
420        if changed_set.contains(candidate) {
421            continue;
422        }
423        let candidate_name = candidate
424            .file_name()
425            .map(|n| n.to_string_lossy().to_lowercase())
426            .unwrap_or_default();
427        let candidate_rel = candidate_rel_path(candidate, repo_root);
428        if matches_any_ref(&candidate_name, &candidate_rel, refs) {
429            discovered.push(candidate.clone());
430        }
431    }
432    discovered
433}
434
435pub fn file_ext(path: &Path) -> String {
436    path.extension()
437        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
438        .unwrap_or_default()
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::types::FragmentKind;
445
446    fn frag(path: &str) -> Fragment {
447        Fragment {
448            id: FragmentId::new(std::sync::Arc::from(path), 1, 10),
449            kind: FragmentKind::Function,
450            content: std::sync::Arc::from("fn x() {}"),
451            identifiers: FxHashSet::default(),
452            token_count: 10,
453            symbol_name: None,
454        }
455    }
456
457    fn linked_paths(reference: &str, paths: &[&str]) -> Vec<String> {
458        let frags: Vec<Fragment> = paths.iter().map(|p| frag(p)).collect();
459        let idx = FragmentIndex::new(&frags, None);
460        let src = frag("src/origin.yml");
461        let mut edges: EdgeDict = FxHashMap::default();
462        link_by_path_match(&src.id, reference, &idx, &mut edges, 0.5, 0.5);
463        // add_edge also writes the reverse edge, whose dst is the source
464        // itself — the question here is only which files got linked.
465        let mut out: Vec<String> = edges
466            .keys()
467            .map(|(_, dst)| dst.path.to_string())
468            .filter(|p| p != "src/origin.yml")
469            .collect();
470        out.sort();
471        out.dedup();
472        out
473    }
474
475    #[test]
476    fn a_reference_matches_only_whole_path_components() {
477        let paths = [
478            "roles/config/tasks/main.yml",
479            "src/preconfigured/app.rs",
480            "src/config.rs",
481            "deep/nested/config",
482        ];
483        let hit = linked_paths("config", &paths);
484        assert!(
485            hit.contains(&"roles/config/tasks/main.yml".to_string()),
486            "interior whole component must match"
487        );
488        assert!(
489            hit.contains(&"deep/nested/config".to_string()),
490            "suffix component must match"
491        );
492        assert!(
493            !hit.contains(&"src/preconfigured/app.rs".to_string()),
494            "substring inside a component must NOT match — that fan-out is the envoy hang"
495        );
496        assert!(
497            !hit.contains(&"src/config.rs".to_string()),
498            "`config` does not name `config.rs`; a file reference carries its extension"
499        );
500    }
501
502    #[test]
503    fn a_multi_component_reference_still_matches_its_file() {
504        let paths = ["source/common/buffer/buffer_impl.h", "other/buffer_impl.h"];
505        let hit = linked_paths("common/buffer/buffer_impl.h", &paths);
506        assert_eq!(hit, vec!["source/common/buffer/buffer_impl.h".to_string()]);
507    }
508
509    #[test]
510    fn a_package_prefix_reference_matches_files_under_it() {
511        let paths = ["pkg/api/server.go", "pkg/api2/other.go"];
512        let hit = linked_paths("pkg/api", &paths);
513        assert_eq!(hit, vec!["pkg/api/server.go".to_string()]);
514    }
515
516    fn linked_paths_rooted(reference: &str, root: &str, paths: &[&str]) -> Vec<String> {
517        let frags: Vec<Fragment> = paths.iter().map(|p| frag(p)).collect();
518        let idx = FragmentIndex::new(&frags, Some(Path::new(root)));
519        let src = frag("/ci/work/repo/src/origin.yml");
520        let mut edges: EdgeDict = FxHashMap::default();
521        link_by_path_match(&src.id, reference, &idx, &mut edges, 0.5, 0.5);
522        let mut out: Vec<String> = edges
523            .keys()
524            .map(|(_, dst)| dst.path.to_string())
525            .filter(|p| p != src.id.path.as_ref())
526            .collect();
527        out.sort();
528        out.dedup();
529        out
530    }
531
532    #[test]
533    fn the_ambiguity_cap_counts_distinct_files_not_index_keys() {
534        // 5 distinct files under a rooted index: the pre-#207 code indexed
535        // each under its absolute AND relative key, so matched.len() was 10
536        // and the cap (8) dropped a reference naming only 5 files.
537        let paths: Vec<String> = (0..5)
538            .map(|i| format!("/ci/work/repo/lib/util/mod_{i}.py"))
539            .collect();
540        let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
541        let hit = linked_paths_rooted("lib/util", "/ci/work/repo", &refs);
542        assert_eq!(hit.len(), 5, "cap must count files, not duplicate keys");
543    }
544
545    #[test]
546    fn host_directory_components_are_not_matchable() {
547        let paths = ["/ci/work/repo/src/app.rs", "/ci/work/repo/src/db.rs"];
548        let hit = linked_paths_rooted("work", "/ci/work/repo", &paths);
549        assert!(
550            hit.is_empty(),
551            "components outside the repo root must not resolve a reference"
552        );
553    }
554}