Skip to main content

code_system_graph_core/
ignore_policy.rs

1//! Shared repository discovery exclusions.
2
3use std::path::{Component, Path, PathBuf};
4
5use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
6use serde::ser::{Serialize, SerializeStruct, Serializer};
7use thiserror::Error;
8
9use crate::ConfigSource;
10
11/// Version included in repository fingerprints when built-in exclusion behavior changes.
12pub const IGNORE_POLICY_VERSION: u8 = 1;
13
14/// Repository metadata and generated state that can never be re-enabled for discovery.
15pub const PROTECTED_EXCLUDES: &[&str] = &[
16    "**/.git/**",
17    "**/.hg/**",
18    "**/.svn/**",
19    "**/.codegraph/**",
20    "**/.code-system-graph/**",
21];
22
23/// Dependency, cache, environment, and build trees excluded unless explicitly re-enabled.
24pub const DEFAULT_EXCLUDES: &[&str] = &[
25    "**/.next/**",
26    "**/.venv/**",
27    "**/.mypy_cache/**",
28    "**/.nox/**",
29    "**/.pytest_cache/**",
30    "**/.ruff_cache/**",
31    "**/.tox/**",
32    "**/venv/**",
33    "**/env/**",
34    "**/site-packages/**",
35    "**/node_modules/**",
36    "**/vendor/**",
37    "**/target/**",
38    "**/dist/**",
39    "**/build/**",
40    "**/__pycache__/**",
41];
42
43const PROTECTED_DIRECTORY_NAMES: &[&str] =
44    &[".git", ".hg", ".svn", ".codegraph", ".code-system-graph"];
45
46/// Invalid user-supplied discovery glob.
47#[derive(Debug, Error)]
48pub enum IgnorePatternError {
49    /// An empty pattern has no deterministic discovery meaning.
50    #[error("ignore pattern must not be empty")]
51    Empty,
52    /// Dot-only and separator-only patterns do not identify a repository path.
53    #[error("ignore pattern `{0}` must contain a repository-relative path component")]
54    MissingPathComponent(String),
55    /// Patterns are always resolved relative to one checkout.
56    #[error("ignore pattern `{0}` must be repository-relative")]
57    Absolute(String),
58    /// Parent traversal could escape the registered checkout.
59    #[error("ignore pattern `{0}` must not contain a `..` component")]
60    ParentTraversal(String),
61    /// Portable patterns use `/` on every operating system.
62    #[error("ignore pattern `{0}` must use `/` separators")]
63    Backslash(String),
64    /// Terminal control and bidirectional characters are unsafe in reported rules.
65    #[error("ignore pattern contains unsafe control or bidirectional characters")]
66    UnsafeCharacters,
67    /// The public glob contract is intentionally limited to portable wildcard syntax.
68    #[error(
69        "ignore pattern `{0}` uses unsupported glob syntax; only `*`, `?`, and whole-component `**` are supported"
70    )]
71    UnsupportedSyntax(String),
72    /// The glob expression is malformed.
73    #[error("invalid ignore pattern `{pattern}`: {detail}")]
74    InvalidGlob {
75        /// Rejected pattern.
76        pattern: String,
77        /// Parser diagnostic.
78        detail: String,
79    },
80    /// Generated state and version-control metadata cannot be re-enabled.
81    #[error("includeDefaults pattern `{pattern}` targets protected directory `{directory}`")]
82    ProtectedInclude {
83        /// Rejected include pattern.
84        pattern: String,
85        /// Protected literal path component.
86        directory: String,
87    },
88}
89
90/// Effective exclusion policy for one registered repository.
91#[derive(Debug, Clone)]
92pub struct IgnorePolicy {
93    configured_excludes: Vec<String>,
94    configured_excludes_source: ConfigSource,
95    include_defaults: Vec<String>,
96    include_defaults_source: ConfigSource,
97    protected_matcher: GlobSet,
98    default_matcher: GlobSet,
99    configured_matcher: GlobSet,
100    include_matcher: GlobSet,
101    include_prefixes: Vec<PathBuf>,
102    include_can_match_anywhere: bool,
103}
104
105impl PartialEq for IgnorePolicy {
106    fn eq(&self, other: &Self) -> bool {
107        self.configured_excludes == other.configured_excludes
108            && self.configured_excludes_source == other.configured_excludes_source
109            && self.include_defaults == other.include_defaults
110            && self.include_defaults_source == other.include_defaults_source
111    }
112}
113
114impl Eq for IgnorePolicy {}
115
116impl Serialize for IgnorePolicy {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: Serializer,
120    {
121        let mut state = serializer.serialize_struct("IgnorePolicy", 4)?;
122        state.serialize_field("configured_excludes", &self.configured_excludes)?;
123        state.serialize_field(
124            "configured_excludes_source",
125            &self.configured_excludes_source,
126        )?;
127        state.serialize_field("include_defaults", &self.include_defaults)?;
128        state.serialize_field("include_defaults_source", &self.include_defaults_source)?;
129        state.end()
130    }
131}
132
133impl IgnorePolicy {
134    /// Builds one validated, compiled repository exclusion policy.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`IgnorePatternError`] when a pattern is unsafe, malformed, or attempts to include
139    /// protected generated state.
140    pub fn new(
141        configured_excludes: Vec<String>,
142        configured_excludes_source: ConfigSource,
143        include_defaults: Vec<String>,
144        include_defaults_source: ConfigSource,
145    ) -> Result<Self, IgnorePatternError> {
146        let mut configured_excludes = normalize_patterns(configured_excludes)?;
147        let mut include_defaults = normalize_patterns(include_defaults)?;
148        validate_protected_includes(&include_defaults)?;
149        configured_excludes.sort();
150        configured_excludes.dedup();
151        include_defaults.sort();
152        include_defaults.dedup();
153        let (include_prefixes, include_can_match_anywhere) = include_prefixes(&include_defaults);
154        Ok(Self {
155            protected_matcher: compile(PROTECTED_EXCLUDES.iter().copied())?,
156            default_matcher: compile(DEFAULT_EXCLUDES.iter().copied())?,
157            configured_matcher: compile(configured_excludes.iter().map(String::as_str))?,
158            include_matcher: compile(include_defaults.iter().map(String::as_str))?,
159            configured_excludes,
160            configured_excludes_source,
161            include_defaults,
162            include_defaults_source,
163            include_prefixes,
164            include_can_match_anywhere,
165        })
166    }
167
168    /// User-supplied exclusions selected by repository configuration precedence.
169    #[must_use]
170    pub fn configured_excludes(&self) -> &[String] {
171        &self.configured_excludes
172    }
173
174    /// Configuration layer that supplied [`Self::configured_excludes`].
175    #[must_use]
176    pub const fn configured_excludes_source(&self) -> ConfigSource {
177        self.configured_excludes_source
178    }
179
180    /// User-supplied exceptions to built-in default exclusions.
181    #[must_use]
182    pub fn include_defaults(&self) -> &[String] {
183        &self.include_defaults
184    }
185
186    /// Configuration layer that supplied [`Self::include_defaults`].
187    #[must_use]
188    pub const fn include_defaults_source(&self) -> ConfigSource {
189        self.include_defaults_source
190    }
191
192    /// Whether one repository-relative path must be omitted from automatic discovery.
193    #[must_use]
194    pub fn excludes(&self, relative: &Path, directory: bool) -> bool {
195        let candidate = candidate(relative);
196        let directory_candidate = directory.then(|| format!("{candidate}/"));
197        let matches = |matcher: &GlobSet| {
198            matcher.is_match(&candidate)
199                || directory_candidate
200                    .as_deref()
201                    .is_some_and(|candidate| matcher.is_match(candidate))
202        };
203        if matches(&self.protected_matcher) || matches(&self.configured_matcher) {
204            return true;
205        }
206        matches(&self.default_matcher)
207            && !matches(&self.include_matcher)
208            && !(directory && self.may_contain_included_default(relative))
209    }
210
211    /// Stable material included in incremental repository fingerprints.
212    #[must_use]
213    pub fn fingerprint_material(&self) -> String {
214        format!(
215            "version={IGNORE_POLICY_VERSION};protected={PROTECTED_EXCLUDES:?};defaults={DEFAULT_EXCLUDES:?};\
216             excludes={:?};excludes_source={:?};include_defaults={:?};include_defaults_source={:?}",
217            self.configured_excludes,
218            self.configured_excludes_source,
219            self.include_defaults,
220            self.include_defaults_source
221        )
222    }
223
224    fn may_contain_included_default(&self, directory: &Path) -> bool {
225        if self.include_can_match_anywhere {
226            return true;
227        }
228        self.include_prefixes
229            .iter()
230            .any(|prefix| prefix.starts_with(directory) || directory.starts_with(prefix))
231    }
232}
233
234/// Validates configured exclusion globs.
235///
236/// # Errors
237///
238/// Returns [`IgnorePatternError`] for unsafe or malformed patterns.
239pub fn validate_excludes(patterns: &[String]) -> Result<(), IgnorePatternError> {
240    let normalized = normalize_patterns(patterns)?;
241    compile(normalized.iter().map(String::as_str)).map(|_| ())
242}
243
244/// Validates exceptions to default exclusions.
245///
246/// # Errors
247///
248/// Returns [`IgnorePatternError`] for unsafe or malformed patterns, including attempts to include
249/// protected repository metadata.
250pub fn validate_include_defaults(patterns: &[String]) -> Result<(), IgnorePatternError> {
251    let normalized = normalize_patterns(patterns)?;
252    validate_protected_includes(&normalized)?;
253    compile(normalized.iter().map(String::as_str)).map(|_| ())
254}
255
256fn validate_protected_includes(patterns: &[String]) -> Result<(), IgnorePatternError> {
257    for pattern in patterns {
258        for component in pattern.split('/') {
259            if PROTECTED_DIRECTORY_NAMES.contains(&component) {
260                return Err(IgnorePatternError::ProtectedInclude {
261                    pattern: pattern.clone(),
262                    directory: component.to_owned(),
263                });
264            }
265        }
266    }
267    Ok(())
268}
269
270fn normalize_patterns<I, S>(patterns: I) -> Result<Vec<String>, IgnorePatternError>
271where
272    I: IntoIterator<Item = S>,
273    S: AsRef<str>,
274{
275    patterns
276        .into_iter()
277        .map(|pattern| normalize_pattern(pattern.as_ref()))
278        .collect()
279}
280
281fn normalize_pattern(pattern: &str) -> Result<String, IgnorePatternError> {
282    if pattern.trim().is_empty() {
283        return Err(IgnorePatternError::Empty);
284    }
285    if absolute_pattern(pattern) {
286        return Err(IgnorePatternError::Absolute(pattern.to_owned()));
287    }
288    if pattern.contains('\\') {
289        return Err(IgnorePatternError::Backslash(pattern.to_owned()));
290    }
291    if pattern.split('/').any(|component| component == "..") {
292        return Err(IgnorePatternError::ParentTraversal(pattern.to_owned()));
293    }
294    if pattern.chars().any(unsafe_character) {
295        return Err(IgnorePatternError::UnsafeCharacters);
296    }
297
298    let directory_only = pattern.ends_with('/')
299        || pattern
300            .split('/')
301            .rfind(|component| !component.is_empty())
302            .is_some_and(|component| component == ".");
303    let components = pattern
304        .split('/')
305        .filter(|component| !component.is_empty() && *component != ".")
306        .collect::<Vec<_>>();
307    if components.is_empty() {
308        return Err(IgnorePatternError::MissingPathComponent(pattern.to_owned()));
309    }
310    validate_supported_syntax(pattern, &components)?;
311
312    let mut normalized = components.join("/");
313    if absolute_pattern(&normalized) {
314        return Err(IgnorePatternError::Absolute(pattern.to_owned()));
315    }
316    if directory_only {
317        normalized.push('/');
318    }
319    Ok(normalized)
320}
321
322fn validate_supported_syntax(pattern: &str, components: &[&str]) -> Result<(), IgnorePatternError> {
323    let unsupported_delimiter = pattern.contains(['[', ']', '{', '}']);
324    let unsupported_recursive = components
325        .iter()
326        .any(|component| component.contains("**") && *component != "**");
327    if unsupported_delimiter || unsupported_recursive {
328        return Err(IgnorePatternError::UnsupportedSyntax(pattern.to_owned()));
329    }
330    Ok(())
331}
332
333fn absolute_pattern(pattern: &str) -> bool {
334    pattern.starts_with('/')
335        || pattern
336            .as_bytes()
337            .get(1)
338            .is_some_and(|separator| *separator == b':')
339}
340
341fn compile<'a>(patterns: impl Iterator<Item = &'a str>) -> Result<GlobSet, IgnorePatternError> {
342    let mut builder = GlobSetBuilder::new();
343    for pattern in patterns {
344        let glob = GlobBuilder::new(pattern)
345            .literal_separator(true)
346            .backslash_escape(false)
347            .build()
348            .map_err(|error| IgnorePatternError::InvalidGlob {
349                pattern: pattern.to_owned(),
350                detail: error.to_string(),
351            })?;
352        builder.add(glob);
353    }
354    builder
355        .build()
356        .map_err(|error| IgnorePatternError::InvalidGlob {
357            pattern: "<set>".to_owned(),
358            detail: error.to_string(),
359        })
360}
361
362fn include_prefixes(patterns: &[String]) -> (Vec<PathBuf>, bool) {
363    let mut prefixes = Vec::new();
364    let mut can_match_anywhere = false;
365    for pattern in patterns {
366        let mut prefix = PathBuf::new();
367        for component in pattern.split('/') {
368            if component.contains(['*', '?']) {
369                break;
370            }
371            if !component.is_empty() {
372                prefix.push(component);
373            }
374        }
375        if prefix.as_os_str().is_empty() {
376            can_match_anywhere = true;
377        } else {
378            prefixes.push(prefix);
379        }
380    }
381    prefixes.sort();
382    prefixes.dedup();
383    (prefixes, can_match_anywhere)
384}
385
386fn candidate(path: &Path) -> String {
387    path.components()
388        .filter_map(|component| match component {
389            Component::Normal(value) => Some(value.to_string_lossy()),
390            _ => None,
391        })
392        .collect::<Vec<_>>()
393        .join("/")
394}
395
396fn unsafe_character(character: char) -> bool {
397    character.is_control()
398        || matches!(
399            character,
400            '\u{061c}'
401                | '\u{200e}'
402                | '\u{200f}'
403                | '\u{202a}'..='\u{202e}'
404                | '\u{2066}'..='\u{2069}'
405        )
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn policy(excludes: &[&str], includes: &[&str]) -> IgnorePolicy {
413        IgnorePolicy::new(
414            excludes.iter().map(ToString::to_string).collect(),
415            ConfigSource::WorkspaceManifest,
416            includes.iter().map(ToString::to_string).collect(),
417            ConfigSource::WorkspaceManifest,
418        )
419        .expect("valid fixture policy")
420    }
421
422    #[test]
423    fn defaults_should_exclude_nested_dependency_tree() {
424        assert!(policy(&[], &[]).excludes(Path::new("web/node_modules/react/index.js"), false));
425    }
426
427    #[test]
428    fn configured_excludes_should_match_repository_relative_globs() {
429        assert!(policy(&["coverage/**"], &[]).excludes(Path::new("coverage/lcov.info"), false));
430    }
431
432    #[test]
433    fn configured_excludes_should_support_single_component_wildcards() {
434        assert!(policy(&["src/*/?.rs"], &[]).excludes(Path::new("src/api/x.rs"), false));
435    }
436
437    #[test]
438    fn configured_excludes_should_normalize_and_deduplicate_patterns() {
439        let policy = policy(&["./coverage//**", "coverage/./**", "coverage/**"], &[]);
440
441        assert_eq!(policy.configured_excludes(), &["coverage/**"]);
442    }
443
444    #[test]
445    fn configured_excludes_should_match_normalized_patterns() {
446        assert!(policy(&["./coverage/./**"], &[]).excludes(Path::new("coverage/lcov.info"), false));
447    }
448
449    #[test]
450    fn directory_patterns_should_preserve_their_terminal_separator() {
451        let policy = policy(&["./coverage//"], &[]);
452
453        assert!(policy.excludes(Path::new("coverage"), true));
454        assert!(!policy.excludes(Path::new("coverage"), false));
455    }
456
457    #[test]
458    fn ordinary_globs_should_match_directories_without_a_terminal_separator() {
459        let policy = policy(&["generated/*"], &[]);
460
461        assert!(policy.excludes(Path::new("generated/output"), true));
462    }
463
464    #[test]
465    fn include_defaults_should_reopen_only_selected_subtree() {
466        let policy = policy(&[], &["./vendor//internal-sdk/./**"]);
467        assert_eq!(
468            (
469                policy.excludes(Path::new("vendor/internal-sdk/src/lib.rs"), false),
470                policy.excludes(Path::new("vendor/external/src/lib.rs"), false),
471            ),
472            (false, true)
473        );
474    }
475
476    #[test]
477    fn configured_excludes_should_override_default_includes() {
478        assert!(
479            policy(
480                &["vendor/internal-sdk/private/**"],
481                &["vendor/internal-sdk/**"]
482            )
483            .excludes(Path::new("vendor/internal-sdk/private/key.rs"), false)
484        );
485    }
486
487    #[test]
488    fn include_defaults_should_keep_ancestor_traversable() {
489        assert!(!policy(&[], &["./vendor//internal-sdk/./**"]).excludes(Path::new("vendor"), true));
490    }
491
492    #[test]
493    fn canonical_equivalent_policies_should_have_the_same_fingerprint() {
494        let canonical = policy(&["coverage/**"], &["vendor/internal-sdk/**"]);
495        let redundant = policy(&["./coverage//./**"], &["./vendor//internal-sdk/./**"]);
496
497        assert_eq!(
498            canonical.fingerprint_material(),
499            redundant.fingerprint_material()
500        );
501    }
502
503    #[test]
504    fn include_defaults_should_reject_protected_directories() {
505        assert!(matches!(
506            validate_include_defaults(&["./.git//config".to_owned()]),
507            Err(IgnorePatternError::ProtectedInclude { .. })
508        ));
509    }
510
511    #[test]
512    fn patterns_should_reject_parent_traversal() {
513        assert!(matches!(
514            validate_excludes(&["./safe/../outside/**".to_owned()]),
515            Err(IgnorePatternError::ParentTraversal(_))
516        ));
517    }
518
519    #[test]
520    fn patterns_should_reject_windows_absolute_paths_after_normalization() {
521        assert!(matches!(
522            validate_excludes(&["./C:/outside/**".to_owned()]),
523            Err(IgnorePatternError::Absolute(_))
524        ));
525    }
526
527    #[test]
528    fn patterns_should_reject_whitespace_only_values() {
529        assert!(matches!(
530            validate_excludes(&["   ".to_owned()]),
531            Err(IgnorePatternError::Empty)
532        ));
533    }
534
535    #[test]
536    fn patterns_should_reject_values_without_path_components() {
537        assert!(matches!(
538            validate_excludes(&["././".to_owned()]),
539            Err(IgnorePatternError::MissingPathComponent(_))
540        ));
541    }
542
543    #[test]
544    fn patterns_should_reject_character_classes() {
545        assert!(matches!(
546            validate_excludes(&["src/[ab]/**".to_owned()]),
547            Err(IgnorePatternError::UnsupportedSyntax(_))
548        ));
549    }
550
551    #[test]
552    fn patterns_should_reject_alternations() {
553        assert!(matches!(
554            validate_excludes(&["{src,test}/**".to_owned()]),
555            Err(IgnorePatternError::UnsupportedSyntax(_))
556        ));
557    }
558
559    #[test]
560    fn patterns_should_reject_non_component_recursive_wildcards() {
561        assert!(matches!(
562            validate_excludes(&["src/**generated/**".to_owned()]),
563            Err(IgnorePatternError::UnsupportedSyntax(_))
564        ));
565    }
566
567    #[test]
568    fn patterns_should_reject_three_star_wildcards() {
569        assert!(matches!(
570            validate_excludes(&["src/***/generated".to_owned()]),
571            Err(IgnorePatternError::UnsupportedSyntax(_))
572        ));
573    }
574}