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    parser::dependency_manifest_language_ids, 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    if filters.is_empty() {
340        return true;
341    }
342    if language_id(path).is_some_and(|language| filters.iter().any(|filter| filter == language)) {
343        return true;
344    }
345    dependency_manifest_language_ids(path).is_some_and(|languages| {
346        languages
347            .iter()
348            .any(|language| filters.iter().any(|filter| filter == language))
349    })
350}
351
352fn default_source_preset_excludes(path: &str) -> bool {
353    let normalized = normalize_path_filter(path);
354    if normalized
355        .rsplit('/')
356        .next()
357        .is_some_and(|file_name| DEFAULT_EXCLUDED_FILENAMES.contains(&file_name))
358    {
359        return true;
360    }
361    if normalized
362        .split('/')
363        .any(|segment| default_excluded_segment_excludes_path(segment, normalized))
364    {
365        return true;
366    }
367    normalized
368        .rsplit_once('.')
369        .map(|(_, extension)| {
370            DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
371        })
372        .unwrap_or(false)
373}
374
375fn default_excluded_segment_excludes_path(segment: &str, path: &str) -> bool {
376    DEFAULT_EXCLUDED_SEGMENTS.contains(&segment)
377        && (segment != DEFAULT_DISTRIBUTION_SEGMENT || distribution_segment_excludes_path(path))
378}
379
380fn distribution_segment_excludes_path(path: &str) -> bool {
381    let segments = path.split('/').collect::<Vec<_>>();
382
383    !distribution_runtime_source_path_is_indexable(path, &segments)
384}
385
386fn distribution_runtime_source_path_is_indexable(path: &str, segments: &[&str]) -> bool {
387    language_id(path).is_some()
388        && !path
389            .rsplit('/')
390            .next()
391            .is_some_and(|file_name| file_name.to_ascii_lowercase().contains(".min."))
392        && segments.windows(3).any(|window| {
393            window[0] == DEFAULT_DISTRIBUTION_SEGMENT
394                && DEFAULT_DISTRIBUTION_LANGUAGE_SEGMENTS.contains(&window[1])
395                && DEFAULT_DISTRIBUTION_RUNTIME_SEGMENTS.contains(&window[2])
396        })
397}
398
399fn explicit_path_filter_opts_into_default_exclusion<'a>(
400    path: &str,
401    filters: impl IntoIterator<Item = &'a String>,
402) -> bool {
403    let path_extension = path
404        .rsplit_once('.')
405        .map(|(_, extension)| extension.to_ascii_lowercase());
406    filters.into_iter().any(|filter| {
407        let filter = normalize_path_filter(filter);
408        if filter.is_empty() || filter == "." {
409            return false;
410        }
411        let filter_segments = filter.split('/').collect::<Vec<_>>();
412        let targets_default_exclusion = filter_segments.iter().any(|segment| {
413            DEFAULT_EXCLUDED_SEGMENTS.contains(segment)
414                || DEFAULT_EXCLUDED_FILENAMES.contains(segment)
415                || segment
416                    .rsplit_once('.')
417                    .map(|(_, ext)| ext.to_ascii_lowercase())
418                    .is_some_and(|extension| {
419                        DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.as_str())
420                    })
421        });
422        if !targets_default_exclusion {
423            return false;
424        }
425        path_matches_filter(path, filter)
426            || filter.strip_prefix("*.").is_some_and(|extension| {
427                path_extension.as_deref() == Some(&extension.to_ascii_lowercase())
428            })
429    })
430}
431
432fn path_matches_filter(path: &str, filter: &str) -> bool {
433    let path = normalize_path_filter(path);
434    let filter = normalize_path_filter(filter);
435    if filter == "." {
436        return true;
437    }
438    !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
439}
440
441fn path_overlaps_filter(path: &str, filter: &str) -> bool {
442    let path = normalize_path_filter(path);
443    let filter = normalize_path_filter(filter);
444    if filter == "." {
445        return true;
446    }
447    !path.is_empty()
448        && !filter.is_empty()
449        && (path == filter
450            || path.starts_with(&format!("{filter}/"))
451            || filter.starts_with(&format!("{path}/")))
452}
453
454fn normalize_path_filter(filter: &str) -> &str {
455    let mut filter = filter.trim_end_matches(['/', '\\']);
456    while let Some(stripped) = filter.strip_prefix("./") {
457        filter = stripped;
458    }
459
460    filter
461}
462
463#[cfg(test)]
464mod tests {
465    use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
466
467    use super::*;
468
469    #[test]
470    fn source_preset_keeps_distribution_runtime_sources_indexable() {
471        assert!(!default_source_preset_excludes(
472            "frontend/dist/js/core/stream.js"
473        ));
474        assert!(!default_source_preset_excludes(
475            "frontend/dist/js/app/bootstrap.js"
476        ));
477        assert!(!default_source_preset_excludes(
478            "web/dist/src/runtime/session.ts"
479        ));
480        assert!(default_source_preset_excludes("dist/bundle.js"));
481        assert!(default_source_preset_excludes(
482            "frontend/dist/js/components/sidebar.js"
483        ));
484        assert!(default_source_preset_excludes(
485            "frontend/dist/js/core/highlight.min.js"
486        ));
487        assert!(default_source_preset_excludes("frontend/dist/css/app.css"));
488        assert!(default_source_preset_excludes(
489            "node_modules/pkg/dist/js/core/index.js"
490        ));
491    }
492
493    #[test]
494    fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
495        let registration = CodeRepositoryRegistration::new(
496            "repo",
497            "alias",
498            "/tmp/repo",
499            vec!["assets/logo.SVG".to_owned()],
500            Vec::new(),
501        )
502        .expect("registration should validate");
503        let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
504            .expect("selector should validate");
505
506        assert!(path_is_selected(
507            "assets/logo.SVG",
508            &registration,
509            &selector
510        ));
511    }
512
513    #[test]
514    fn default_source_preset_excludes_dataset_dumps_and_uv_lock() {
515        let registration = CodeRepositoryRegistration::new(
516            "repo",
517            "alias",
518            "/tmp/repo",
519            vec![".".to_owned()],
520            Vec::new(),
521        )
522        .expect("registration should validate");
523        let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
524            .expect("selector should validate");
525
526        assert!(!path_is_selected(
527            ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
528            &registration,
529            &selector
530        ));
531        assert!(!path_is_selected("uv.lock", &registration, &selector));
532    }
533
534    #[test]
535    fn nonstandard_source_roots_are_selected_without_opt_in() {
536        let registration = CodeRepositoryRegistration::new(
537            "repo",
538            "alias",
539            "/tmp/repo",
540            vec![".".to_owned()],
541            Vec::new(),
542        )
543        .expect("registration should validate");
544        let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
545            .expect("selector should validate");
546
547        for path in [
548            "external_deps/python_sdk/session_client.py",
549            "packages/ui/src/index.ts",
550            "modules/java_sdk/src/main/java/example/SessionClient.java",
551            "plugins/example.com/nonstandard/session/client.go",
552            "Sources/SwiftSdk/SessionClient.swift",
553            "lib/app/controller.rb",
554        ] {
555            assert!(path_is_selected(path, &registration, &selector), "{path}");
556        }
557        assert!(!path_is_selected(
558            "vendor/pkg/session_client.py",
559            &registration,
560            &selector
561        ));
562        assert!(!path_is_selected(
563            "third_party/pkg/session_client.py",
564            &registration,
565            &selector
566        ));
567    }
568
569    #[test]
570    fn explicit_vendor_source_opt_in_stays_supported() {
571        let registration = CodeRepositoryRegistration::new(
572            "repo",
573            "alias",
574            "/tmp/repo",
575            vec![".".to_owned(), "vendor".to_owned()],
576            Vec::new(),
577        )
578        .expect("registration should validate");
579        let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
580            .expect("selector should validate");
581
582        assert!(path_is_selected(
583            "vendor/pkg/session_client.py",
584            &registration,
585            &selector
586        ));
587    }
588}