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 the containment star spreads its mass inside
101    /// the file.
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        let reps = file_representatives(fragments);
140        let mut lower_paths: Vec<(String, FragmentId)> = Vec::with_capacity(by_path.len());
141        let mut component_to_paths: FxHashMap<String, Vec<u32>> = FxHashMap::default();
142        for (path_str, ids) in &by_path {
143            // Every id under one path key belongs to one file; its
144            // representative is looked up by the id's own (canonical) path,
145            // which also covers the relative-variant keys.
146            let Some(rep) = ids.first().and_then(|id| reps.get(id.path.as_ref())) else {
147                continue;
148            };
149            let idx = lower_paths.len() as u32;
150            let lower = path_str.to_lowercase();
151            for comp in lower.split('/').filter(|c| !c.is_empty()) {
152                let posting = component_to_paths.entry(comp.to_string()).or_default();
153                if posting.last() != Some(&idx) {
154                    posting.push(idx);
155                }
156            }
157            lower_paths.push((lower, rep.clone()));
158        }
159
160        Self {
161            by_name,
162            by_path,
163            lower_paths,
164            component_to_paths,
165        }
166    }
167}
168
169/// One representative fragment per file — the largest by token count, ties
170/// resolved by first-seen order. This is the semantics `SiblingEdgeBuilder`
171/// always used for its file-level edges; file-level *relations* (an include,
172/// a header/impl pair, a path reference) link representatives rather than
173/// every-fragment-to-every-fragment, because the relation names the file. A
174/// change endorsing a file diffusely must not outweigh a call edge naming one
175/// symbol — the fragment-pair encoding gave both the same weight, which is
176/// simultaneously the quadratic edge blow-up (envoy: 22M c-family edges) and
177/// the file-level noise mechanism of #65. Reachability of the file's other
178/// fragments is the containment star's job.
179pub fn file_representatives(fragments: &[Fragment]) -> FxHashMap<String, FragmentId> {
180    let mut file_to_rep: FxHashMap<String, FragmentId> = FxHashMap::default();
181    let mut file_to_token_count: FxHashMap<String, u32> = FxHashMap::default();
182
183    for f in fragments {
184        let path = f.path().to_string();
185        let existing_count = file_to_token_count.get(&path).copied().unwrap_or(0);
186        if !file_to_rep.contains_key(&path) || f.token_count > existing_count {
187            file_to_rep.insert(path.clone(), f.id.clone());
188            file_to_token_count.insert(path, f.token_count);
189        }
190    }
191
192    file_to_rep
193}
194
195pub fn add_edge(
196    edges: &mut EdgeDict,
197    src: &FragmentId,
198    dst: &FragmentId,
199    weight: f64,
200    reverse_factor: f64,
201) {
202    let key_fwd = (src.clone(), dst.clone());
203    let existing_fwd = edges.get(&key_fwd).copied().unwrap_or(0.0);
204    if weight > existing_fwd {
205        edges.insert(key_fwd, weight);
206    }
207    let rev_w = weight * reverse_factor;
208    let key_rev = (dst.clone(), src.clone());
209    let existing_rev = edges.get(&key_rev).copied().unwrap_or(0.0);
210    if rev_w > existing_rev {
211        edges.insert(key_rev, rev_w);
212    }
213}
214
215pub fn add_edge_unidirectional(
216    edges: &mut EdgeDict,
217    src: &FragmentId,
218    dst: &FragmentId,
219    weight: f64,
220) {
221    let key = (src.clone(), dst.clone());
222    let existing = edges.get(&key).copied().unwrap_or(0.0);
223    if weight > existing {
224        edges.insert(key, weight);
225    }
226}
227
228pub fn add_edges_from_ids(
229    edges: &mut EdgeDict,
230    src: &FragmentId,
231    targets: &[FragmentId],
232    weight: f64,
233    reverse_factor: f64,
234) {
235    for target in targets {
236        if target != src {
237            add_edge(edges, src, target, weight, reverse_factor);
238        }
239    }
240}
241
242pub fn link_by_name(
243    src_id: &FragmentId,
244    name: &str,
245    idx: &FragmentIndex,
246    edges: &mut EdgeDict,
247    weight: f64,
248    reverse_factor: f64,
249) {
250    let target = name.split('/').next_back().unwrap_or(name).to_lowercase();
251    if let Some(frag_ids) = idx.by_name.get(&target) {
252        for fid in frag_ids {
253            if fid != src_id {
254                add_edge(edges, src_id, fid, weight, reverse_factor);
255                return;
256            }
257        }
258    }
259    link_by_path_match(src_id, name, idx, edges, weight, reverse_factor);
260}
261
262/// Links `src_id` to fragments of files the reference plausibly names.
263///
264/// A match must be component-aligned: the reference equals the path, a full
265/// suffix of it, or a full prefix (a bazel package label names a directory).
266/// The previous `contains` accepted a match *anywhere* in the path, so a short
267/// generic reference — bazel deps and ansible roles are full of `config`,
268/// `test`, `common` — matched thousands of paths and linked every fragment of
269/// each, which is one of the two mechanisms behind envoy-class instances
270/// hanging the graph build (50% of dcbench).
271pub fn link_by_path_match(
272    src_id: &FragmentId,
273    ref_str: &str,
274    idx: &FragmentIndex,
275    edges: &mut EdgeDict,
276    weight: f64,
277    reverse_factor: f64,
278) {
279    let ref_norm = ref_str.trim_matches('/');
280    if ref_norm.is_empty() {
281        return;
282    }
283    // Case folding subsumes the exact comparison, so matching is done entirely
284    // in lowercase. The last reference component narrows the scan: it must be
285    // a whole component of any aligned path, so only its posting list is
286    // visited instead of every indexed path — the full scan was where an
287    // envoy-scale bazel/ansible pass burned its 240s (~1e5 refs x ~1e4 paths,
288    // an allocation per probe).
289    let ref_lower = ref_norm.to_lowercase();
290    let Some(last) = ref_lower.split('/').next_back() else {
291        return;
292    };
293    let Some(posting) = idx.component_to_paths.get(last) else {
294        return;
295    };
296    let needle = format!("/{ref_lower}/");
297    let matched: Vec<&FragmentId> = posting
298        .iter()
299        .filter_map(|&pi| {
300            let (path_lower, rep) = &idx.lower_paths[pi as usize];
301            component_aligned(path_lower, &ref_lower, &needle).then_some(rep)
302        })
303        .collect();
304    // A reference resolving to more files than this names a *region*, not a
305    // dependency: a bazel label for a directory of hundreds of files says
306    // nothing about which of them relates to the change, and emitting an edge
307    // to every fragment of every one is the remaining half of the envoy hang
308    // (system time: the EdgeDict alone reached page-fault territory).
309    if matched.len() > MAX_FILES_PER_PATH_REF {
310        return;
311    }
312    for rep in matched {
313        if rep != src_id {
314            add_edge(edges, src_id, rep, weight, reverse_factor);
315        }
316    }
317}
318
319/// Same ambiguity bar as `CFamilySemanticWeights::max_files_per_name`, applied
320/// to path references.
321const MAX_FILES_PER_PATH_REF: usize = 8;
322
323fn component_aligned(path: &str, reference: &str, interior_needle: &str) -> bool {
324    if path == reference {
325        return true;
326    }
327    if let Some(rest) = path.strip_suffix(reference) {
328        if rest.ends_with('/') {
329            return true;
330        }
331    }
332    if let Some(rest) = path.strip_prefix(reference) {
333        if rest.starts_with('/') {
334            return true;
335        }
336    }
337    // A directory reference matching an interior span of the path
338    // (`roles/<name>/tasks/main.yml`) still counts, but only whole components:
339    // `config` must not match `preconfigured/`.
340    path.contains(interior_needle)
341}
342
343pub fn read_file_cached<'a>(
344    path: &Path,
345    cache: Option<&'a FxHashMap<PathBuf, String>>,
346) -> Option<String> {
347    if let Some(c) = cache {
348        if let Some(content) = c.get(path) {
349            return Some(content.clone());
350        }
351    }
352    std::fs::read_to_string(path).ok()
353}
354
355fn candidate_rel_path(candidate: &Path, repo_root: Option<&Path>) -> String {
356    if let Some(root) = repo_root {
357        if let Ok(rel) = candidate.strip_prefix(root) {
358            return rel.to_string_lossy().to_lowercase();
359        }
360    }
361    candidate
362        .file_name()
363        .map(|n| n.to_string_lossy().to_lowercase())
364        .unwrap_or_default()
365}
366
367fn matches_any_ref(candidate_name: &str, candidate_rel: &str, refs: &FxHashSet<String>) -> bool {
368    for r in refs {
369        let ref_name = r.split('/').next_back().unwrap_or(r).to_lowercase();
370        if candidate_name == ref_name {
371            return true;
372        }
373        let ref_lower = r.to_lowercase();
374        if ref_lower.len() >= SEMANTIC_DISCOVERY.min_ref_length_for_path_match {
375            if let Some(idx) = candidate_rel.find(&ref_lower) {
376                let end_idx = idx + ref_lower.len();
377                let start_ok = idx == 0
378                    || candidate_rel.as_bytes().get(idx - 1) == Some(&b'/')
379                    || candidate_rel.as_bytes().get(idx - 1) == Some(&b'\\');
380                let end_ok = end_idx == candidate_rel.len()
381                    || matches!(
382                        candidate_rel.as_bytes().get(end_idx),
383                        Some(b'/') | Some(b'\\') | Some(b'.')
384                    );
385                if start_ok && end_ok {
386                    return true;
387                }
388            }
389        }
390    }
391    false
392}
393
394pub fn discover_files_by_refs(
395    refs: &FxHashSet<String>,
396    changed_files: &[PathBuf],
397    all_candidates: &[PathBuf],
398    repo_root: Option<&Path>,
399) -> Vec<PathBuf> {
400    if refs.is_empty() {
401        return vec![];
402    }
403    let changed_set: FxHashSet<&PathBuf> = changed_files.iter().collect();
404    let mut discovered = Vec::new();
405    for candidate in all_candidates {
406        if changed_set.contains(candidate) {
407            continue;
408        }
409        let candidate_name = candidate
410            .file_name()
411            .map(|n| n.to_string_lossy().to_lowercase())
412            .unwrap_or_default();
413        let candidate_rel = candidate_rel_path(candidate, repo_root);
414        if matches_any_ref(&candidate_name, &candidate_rel, refs) {
415            discovered.push(candidate.clone());
416        }
417    }
418    discovered
419}
420
421pub fn file_ext(path: &Path) -> String {
422    path.extension()
423        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
424        .unwrap_or_default()
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::types::FragmentKind;
431
432    fn frag(path: &str) -> Fragment {
433        Fragment {
434            id: FragmentId::new(std::sync::Arc::from(path), 1, 10),
435            kind: FragmentKind::Function,
436            content: std::sync::Arc::from("fn x() {}"),
437            identifiers: FxHashSet::default(),
438            token_count: 10,
439            symbol_name: None,
440        }
441    }
442
443    fn linked_paths(reference: &str, paths: &[&str]) -> Vec<String> {
444        let frags: Vec<Fragment> = paths.iter().map(|p| frag(p)).collect();
445        let idx = FragmentIndex::new(&frags, None);
446        let src = frag("src/origin.yml");
447        let mut edges: EdgeDict = FxHashMap::default();
448        link_by_path_match(&src.id, reference, &idx, &mut edges, 0.5, 0.5);
449        // add_edge also writes the reverse edge, whose dst is the source
450        // itself — the question here is only which files got linked.
451        let mut out: Vec<String> = edges
452            .keys()
453            .map(|(_, dst)| dst.path.to_string())
454            .filter(|p| p != "src/origin.yml")
455            .collect();
456        out.sort();
457        out.dedup();
458        out
459    }
460
461    #[test]
462    fn a_reference_matches_only_whole_path_components() {
463        let paths = [
464            "roles/config/tasks/main.yml",
465            "src/preconfigured/app.rs",
466            "src/config.rs",
467            "deep/nested/config",
468        ];
469        let hit = linked_paths("config", &paths);
470        assert!(
471            hit.contains(&"roles/config/tasks/main.yml".to_string()),
472            "interior whole component must match"
473        );
474        assert!(
475            hit.contains(&"deep/nested/config".to_string()),
476            "suffix component must match"
477        );
478        assert!(
479            !hit.contains(&"src/preconfigured/app.rs".to_string()),
480            "substring inside a component must NOT match — that fan-out is the envoy hang"
481        );
482        assert!(
483            !hit.contains(&"src/config.rs".to_string()),
484            "`config` does not name `config.rs`; a file reference carries its extension"
485        );
486    }
487
488    #[test]
489    fn a_multi_component_reference_still_matches_its_file() {
490        let paths = ["source/common/buffer/buffer_impl.h", "other/buffer_impl.h"];
491        let hit = linked_paths("common/buffer/buffer_impl.h", &paths);
492        assert_eq!(hit, vec!["source/common/buffer/buffer_impl.h".to_string()]);
493    }
494
495    #[test]
496    fn a_package_prefix_reference_matches_files_under_it() {
497        let paths = ["pkg/api/server.go", "pkg/api2/other.go"];
498        let hit = linked_paths("pkg/api", &paths);
499        assert_eq!(hit, vec!["pkg/api/server.go".to_string()]);
500    }
501}