Skip to main content

relay_knowledge/code/
scope.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use crate::domain::{
8    CodeImpactPathGroups, CodeRepositoryExcludedPath, CodeRepositoryLanguagePreview,
9    CodeRepositoryLargestFile, CodeRepositoryRegistration, CodeRepositoryScopePreview,
10    CodeRepositorySelector,
11};
12
13use super::{
14    CodeIndexError, changes::tracked_entries, git_bytes, git_object_exists, languages::language_id,
15    resolve_ref, resolve_tree,
16};
17
18const PREVIEW_MAX_EXCLUDED_PATHS: usize = 50;
19const PREVIEW_MAX_LARGEST_FILES: usize = 10;
20const DEFAULT_TEXT_FILE_BUDGET_BYTES: usize = 512 * 1024;
21const DEFAULT_EXCLUDED_SEGMENTS: &[&str] = &[
22    ".git",
23    ".cache",
24    ".next",
25    ".nuxt",
26    ".parcel-cache",
27    ".pytest_cache",
28    ".ruff_cache",
29    ".tox",
30    ".venv",
31    "__pycache__",
32    "build",
33    "coverage",
34    "dist",
35    "node_modules",
36    "out",
37    "target",
38    "third_party",
39    "vendor",
40    "venv",
41];
42const DEFAULT_EXCLUDED_EXTENSIONS: &[&str] = &[
43    "7z", "avif", "bmp", "bz2", "class", "eot", "gif", "gz", "ico", "jar", "jpeg", "jpg", "jsonl",
44    "lockb", "map", "mov", "mp4", "otf", "pdf", "png", "svg", "tar", "tgz", "ttf", "wasm", "webm",
45    "woff", "woff2", "zip", "zst",
46];
47const DEFAULT_EXCLUDED_FILENAMES: &[&str] = &[".relay-knowledgeignore", "uv.lock"];
48const DEFAULT_DISTRIBUTION_SEGMENT: &str = "dist";
49const DEFAULT_DISTRIBUTION_LANGUAGE_SEGMENTS: &[&str] = &[
50    "javascript",
51    "js",
52    "src",
53    "source",
54    "sources",
55    "ts",
56    "typescript",
57];
58const DEFAULT_DISTRIBUTION_RUNTIME_SEGMENTS: &[&str] =
59    &["app", "client", "core", "runtime", "server"];
60
61/// Returns a non-mutating preview of the effective repository indexing scope.
62pub fn preview_repository_scope(
63    registration: &CodeRepositoryRegistration,
64    selector: &CodeRepositorySelector,
65) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
66    let root = PathBuf::from(&registration.root_path);
67    let commit = resolve_ref(&root, &selector.ref_selector)?;
68    let tree_hash = resolve_tree(&root, &commit)?;
69    let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
70    let mut selected_byte_count = 0usize;
71    let mut selected_file_count = 0usize;
72    let mut unsupported_file_count = 0usize;
73    let mut generated_or_heavy_file_count = 0usize;
74    let mut expected_degraded_file_count = 0usize;
75    let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
76    let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
77    let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
78
79    for entry in tracked_entries(&root, &commit)? {
80        if let Some(reason) =
81            selection_exclusion_reason(&entry.path, registration, selector, &ignore_rules)
82        {
83            if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
84                excluded_paths.push(CodeRepositoryExcludedPath {
85                    path: entry.path,
86                    reason,
87                });
88            }
89            continue;
90        }
91        let language = language_id(&entry.path).unwrap_or("unknown");
92        selected_file_count += 1;
93        selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
94        let bucket = language_distribution
95            .entry(language.to_owned())
96            .or_insert((0, 0));
97        bucket.0 += 1;
98        bucket.1 = bucket.1.saturating_add(entry.byte_count);
99        let is_unsupported = language == "unknown";
100        let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
101        if is_unsupported {
102            unsupported_file_count += 1;
103        }
104        if is_heavy {
105            generated_or_heavy_file_count += 1;
106        }
107        if is_unsupported || is_heavy {
108            expected_degraded_file_count += 1;
109        }
110        largest_files.push(CodeRepositoryLargestFile {
111            path: entry.path,
112            byte_count: entry.byte_count,
113        });
114    }
115    largest_files.sort_by(|left, right| {
116        right
117            .byte_count
118            .cmp(&left.byte_count)
119            .then_with(|| left.path.cmp(&right.path))
120    });
121    largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
122
123    Ok(CodeRepositoryScopePreview {
124        repository_id: registration.repository_id.clone(),
125        alias: registration.alias.clone(),
126        requested_ref: selector.ref_selector.clone(),
127        resolved_commit_sha: commit,
128        tree_hash,
129        selected_file_count,
130        selected_byte_count,
131        unsupported_file_count,
132        generated_or_heavy_file_count,
133        expected_degraded_file_count,
134        language_distribution: language_distribution
135            .into_iter()
136            .map(
137                |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
138                    language_id,
139                    file_count,
140                    byte_count,
141                },
142            )
143            .collect(),
144        largest_files,
145        excluded_paths,
146    })
147}
148
149/// Splits diff paths by the same selector rules used by indexing and impact.
150pub fn partition_changed_paths_for_selector(
151    registration: &CodeRepositoryRegistration,
152    selector: &CodeRepositorySelector,
153    paths: Vec<String>,
154) -> Result<CodeImpactPathGroups, CodeIndexError> {
155    let root = PathBuf::from(&registration.root_path);
156    let commit = resolve_ref(&root, &selector.ref_selector)?;
157    let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
158    let mut in_scope_changed_paths = Vec::new();
159    let mut out_of_scope_changed_paths = Vec::new();
160    for path in paths {
161        if selection_exclusion_reason(&path, registration, selector, &ignore_rules).is_none() {
162            in_scope_changed_paths.push(path);
163        } else {
164            out_of_scope_changed_paths.push(path);
165        }
166    }
167    in_scope_changed_paths.sort();
168    in_scope_changed_paths.dedup();
169    out_of_scope_changed_paths.sort();
170    out_of_scope_changed_paths.dedup();
171
172    Ok(CodeImpactPathGroups {
173        in_scope_changed_paths,
174        out_of_scope_changed_paths,
175    })
176}
177
178#[cfg(test)]
179pub(super) fn path_is_selected(
180    path: &str,
181    registration: &CodeRepositoryRegistration,
182    selector: &CodeRepositorySelector,
183) -> bool {
184    let root = Path::new(&registration.root_path);
185    let ignore_rules = load_ignore_rules(root).expect("ignore rules should load in tests");
186
187    path_is_selected_with_rules(path, registration, selector, &ignore_rules)
188}
189
190pub(super) fn path_is_selected_with_rules(
191    path: &str,
192    registration: &CodeRepositoryRegistration,
193    selector: &CodeRepositorySelector,
194    ignore_rules: &[IgnoreRule],
195) -> bool {
196    selection_exclusion_reason(path, registration, selector, ignore_rules).is_none()
197}
198
199pub(super) fn selection_exclusion_reason(
200    path: &str,
201    registration: &CodeRepositoryRegistration,
202    selector: &CodeRepositorySelector,
203    ignore_rules: &[IgnoreRule],
204) -> Option<String> {
205    if !path_scope_allows(path, registration, selector) {
206        return Some("outside registered/requested path scope".to_owned());
207    }
208    if !language_filter_allows(path, &registration.language_filters)
209        || !language_filter_allows(path, &selector.language_filters)
210    {
211        return Some("outside registered/requested language scope".to_owned());
212    }
213    if ignore_rules.iter().any(|rule| rule.matches(path)) {
214        return Some("excluded by .relay-knowledgeignore".to_owned());
215    }
216    if default_source_preset_excludes(path)
217        && !explicit_path_filter_opts_into_default_exclusion(
218            path,
219            registration
220                .path_filters
221                .iter()
222                .chain(selector.path_filters.iter()),
223        )
224    {
225        return Some("excluded by source preset".to_owned());
226    }
227
228    None
229}
230
231pub(super) fn path_scope_allows(
232    path: &str,
233    registration: &CodeRepositoryRegistration,
234    selector: &CodeRepositorySelector,
235) -> bool {
236    path_filter_allows(path, &registration.path_filters)
237        && path_filter_allows(path, &selector.path_filters)
238}
239
240pub(super) fn path_scope_overlaps(
241    path: &str,
242    registration: &CodeRepositoryRegistration,
243    selector: &CodeRepositorySelector,
244) -> bool {
245    path_filter_overlaps(path, &registration.path_filters)
246        && path_filter_overlaps(path, &selector.path_filters)
247}
248
249pub(super) fn load_ignore_rules(root: &Path) -> Result<Vec<IgnoreRule>, CodeIndexError> {
250    let path = root.join(".relay-knowledgeignore");
251    let content = match fs::read_to_string(path) {
252        Ok(content) => content,
253        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
254        Err(error) => return Err(error.into()),
255    };
256
257    Ok(parse_ignore_rules(&content))
258}
259
260pub(super) fn load_ignore_rules_from_commit(
261    root: &Path,
262    commit: &str,
263) -> Result<Vec<IgnoreRule>, CodeIndexError> {
264    let object = format!("{commit}:.relay-knowledgeignore");
265    if !git_object_exists(root, &object)? {
266        return Ok(Vec::new());
267    }
268    let content = String::from_utf8(git_bytes(root, ["show", &object])?).map_err(|error| {
269        CodeIndexError::InvalidInput(format!(
270            ".relay-knowledgeignore at {commit} is not valid UTF-8: {}",
271            error.utf8_error()
272        ))
273    })?;
274
275    Ok(parse_ignore_rules(&content))
276}
277
278fn parse_ignore_rules(content: &str) -> Vec<IgnoreRule> {
279    content
280        .lines()
281        .map(str::trim)
282        .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('!'))
283        .map(|line| IgnoreRule {
284            pattern: line.trim_start_matches('/').to_owned(),
285            anchored: line.starts_with('/'),
286        })
287        .collect()
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub(super) struct IgnoreRule {
292    pattern: String,
293    anchored: bool,
294}
295
296impl IgnoreRule {
297    fn matches(&self, path: &str) -> bool {
298        let pattern = normalize_path_filter(&self.pattern);
299        let path = normalize_path_filter(path);
300        if pattern.is_empty() {
301            return false;
302        }
303        if let Some(extension) = pattern.strip_prefix("*.") {
304            return if self.anchored {
305                path.rsplit_once('/').is_none()
306                    && path
307                        .rsplit_once('.')
308                        .is_some_and(|(_, path_extension)| path_extension == extension)
309            } else {
310                path.rsplit_once('.')
311                    .is_some_and(|(_, path_extension)| path_extension == extension)
312            };
313        }
314        if pattern.contains('/') {
315            return path == pattern || path.starts_with(&format!("{pattern}/"));
316        }
317        if self.anchored {
318            return path == pattern || path.starts_with(&format!("{pattern}/"));
319        }
320        path.split('/').any(|segment| segment == pattern)
321    }
322}
323
324fn path_filter_allows(path: &str, filters: &[String]) -> bool {
325    filters.is_empty()
326        || filters
327            .iter()
328            .any(|filter| path_matches_filter(path, filter))
329}
330
331fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
332    filters.is_empty()
333        || filters
334            .iter()
335            .any(|filter| path_overlaps_filter(path, filter))
336}
337
338fn language_filter_allows(path: &str, filters: &[String]) -> bool {
339    filters.is_empty()
340        || language_id(path)
341            .map(|language| filters.iter().any(|filter| filter == language))
342            .unwrap_or(false)
343}
344
345fn default_source_preset_excludes(path: &str) -> bool {
346    let normalized = normalize_path_filter(path);
347    if normalized
348        .rsplit('/')
349        .next()
350        .is_some_and(|file_name| DEFAULT_EXCLUDED_FILENAMES.contains(&file_name))
351    {
352        return true;
353    }
354    if normalized
355        .split('/')
356        .any(|segment| default_excluded_segment_excludes_path(segment, normalized))
357    {
358        return true;
359    }
360    normalized
361        .rsplit_once('.')
362        .map(|(_, extension)| {
363            DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
364        })
365        .unwrap_or(false)
366}
367
368fn default_excluded_segment_excludes_path(segment: &str, path: &str) -> bool {
369    DEFAULT_EXCLUDED_SEGMENTS.contains(&segment)
370        && (segment != DEFAULT_DISTRIBUTION_SEGMENT || distribution_segment_excludes_path(path))
371}
372
373fn distribution_segment_excludes_path(path: &str) -> bool {
374    let segments = path.split('/').collect::<Vec<_>>();
375
376    !distribution_runtime_source_path_is_indexable(path, &segments)
377}
378
379fn distribution_runtime_source_path_is_indexable(path: &str, segments: &[&str]) -> bool {
380    language_id(path).is_some()
381        && !path
382            .rsplit('/')
383            .next()
384            .is_some_and(|file_name| file_name.to_ascii_lowercase().contains(".min."))
385        && segments.windows(3).any(|window| {
386            window[0] == DEFAULT_DISTRIBUTION_SEGMENT
387                && DEFAULT_DISTRIBUTION_LANGUAGE_SEGMENTS.contains(&window[1])
388                && DEFAULT_DISTRIBUTION_RUNTIME_SEGMENTS.contains(&window[2])
389        })
390}
391
392fn explicit_path_filter_opts_into_default_exclusion<'a>(
393    path: &str,
394    filters: impl IntoIterator<Item = &'a String>,
395) -> bool {
396    let path_extension = path
397        .rsplit_once('.')
398        .map(|(_, extension)| extension.to_ascii_lowercase());
399    filters.into_iter().any(|filter| {
400        let filter = normalize_path_filter(filter);
401        if filter.is_empty() || filter == "." {
402            return false;
403        }
404        let filter_segments = filter.split('/').collect::<Vec<_>>();
405        let targets_default_exclusion = filter_segments.iter().any(|segment| {
406            DEFAULT_EXCLUDED_SEGMENTS.contains(segment)
407                || DEFAULT_EXCLUDED_FILENAMES.contains(segment)
408                || segment
409                    .rsplit_once('.')
410                    .map(|(_, ext)| ext.to_ascii_lowercase())
411                    .is_some_and(|extension| {
412                        DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.as_str())
413                    })
414        });
415        if !targets_default_exclusion {
416            return false;
417        }
418        path_matches_filter(path, filter)
419            || filter.strip_prefix("*.").is_some_and(|extension| {
420                path_extension.as_deref() == Some(&extension.to_ascii_lowercase())
421            })
422    })
423}
424
425fn path_matches_filter(path: &str, filter: &str) -> bool {
426    let path = normalize_path_filter(path);
427    let filter = normalize_path_filter(filter);
428    if filter == "." {
429        return true;
430    }
431    !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
432}
433
434fn path_overlaps_filter(path: &str, filter: &str) -> bool {
435    let path = normalize_path_filter(path);
436    let filter = normalize_path_filter(filter);
437    if filter == "." {
438        return true;
439    }
440    !path.is_empty()
441        && !filter.is_empty()
442        && (path == filter
443            || path.starts_with(&format!("{filter}/"))
444            || filter.starts_with(&format!("{path}/")))
445}
446
447fn normalize_path_filter(filter: &str) -> &str {
448    let mut filter = filter.trim_end_matches(['/', '\\']);
449    while let Some(stripped) = filter.strip_prefix("./") {
450        filter = stripped;
451    }
452
453    filter
454}
455
456#[cfg(test)]
457mod tests {
458    use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
459
460    use super::*;
461
462    #[test]
463    fn source_preset_keeps_distribution_runtime_sources_indexable() {
464        assert!(!default_source_preset_excludes(
465            "frontend/dist/js/core/stream.js"
466        ));
467        assert!(!default_source_preset_excludes(
468            "frontend/dist/js/app/bootstrap.js"
469        ));
470        assert!(!default_source_preset_excludes(
471            "web/dist/src/runtime/session.ts"
472        ));
473        assert!(default_source_preset_excludes("dist/bundle.js"));
474        assert!(default_source_preset_excludes(
475            "frontend/dist/js/components/sidebar.js"
476        ));
477        assert!(default_source_preset_excludes(
478            "frontend/dist/js/core/highlight.min.js"
479        ));
480        assert!(default_source_preset_excludes("frontend/dist/css/app.css"));
481        assert!(default_source_preset_excludes(
482            "node_modules/pkg/dist/js/core/index.js"
483        ));
484    }
485
486    #[test]
487    fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
488        let registration = CodeRepositoryRegistration::new(
489            "repo",
490            "alias",
491            "/tmp/repo",
492            vec!["assets/logo.SVG".to_owned()],
493            Vec::new(),
494        )
495        .expect("registration should validate");
496        let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
497            .expect("selector should validate");
498
499        assert!(path_is_selected(
500            "assets/logo.SVG",
501            &registration,
502            &selector
503        ));
504    }
505
506    #[test]
507    fn default_source_preset_excludes_dataset_dumps_and_uv_lock() {
508        let registration = CodeRepositoryRegistration::new(
509            "repo",
510            "alias",
511            "/tmp/repo",
512            vec![".".to_owned()],
513            Vec::new(),
514        )
515        .expect("registration should validate");
516        let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
517            .expect("selector should validate");
518
519        assert!(!path_is_selected(
520            ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
521            &registration,
522            &selector
523        ));
524        assert!(!path_is_selected("uv.lock", &registration, &selector));
525    }
526}