repopilot 0.18.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use crate::analysis::{ArchitectureClassifier, FileRole};
use crate::audits::traits::ProjectAudit;
use crate::findings::types::{Evidence, Finding, FindingCategory, Severity};
use crate::scan::config::ScanConfig;
use crate::scan::facts::ScanFacts;
use std::path::{Component, Path, PathBuf};

pub struct DeepDirectoryNestingAudit;

impl ProjectAudit for DeepDirectoryNestingAudit {
    fn audit(&self, facts: &ScanFacts, config: &ScanConfig) -> Vec<Finding> {
        let classifier = ArchitectureClassifier::new(&config.module_mappings);
        facts
            .files
            .iter()
            .filter_map(|file| {
                let context = classifier.classify(file);
                if context.file_role == FileRole::Production {
                    // Measure nesting relative to the scan root so the depth is
                    // about the repository's own layout, not how deep the repo
                    // happens to sit on disk (an absolute scan path would
                    // otherwise inflate every file's depth).
                    let relative = file
                        .path
                        .strip_prefix(&facts.root_path)
                        .unwrap_or(&file.path);
                    let depth = compute_directory_nesting(relative);
                    if depth > config.max_directory_depth {
                        Some(build_finding(
                            file.path.clone(),
                            depth,
                            config.max_directory_depth,
                        ))
                    } else {
                        None
                    }
                } else {
                    None
                }
            })
            .collect()
    }
}

fn compute_directory_nesting(path: &Path) -> usize {
    path.components()
        .filter(|c| matches!(c, Component::Normal(_)))
        .count()
        .saturating_sub(1)
}

fn build_finding(path: PathBuf, depth: usize, threshold: usize) -> Finding {
    Finding {
        id: String::new(),
        rule_id: "architecture.deep-directory-nesting".to_string(),
        recommendation: Finding::recommendation_for_rule_id("architecture.deep-directory-nesting"),
        title: "Deep directory nesting detected".to_string(),
        description: format!(
            "This file is nested {depth} directories deep, exceeding the threshold \
             of {threshold}. Deep directory nesting makes the codebase harder to navigate."
        ),
        category: FindingCategory::Architecture,
        severity: Severity::Low,
        confidence: Default::default(),
        evidence: vec![Evidence {
            path,
            line_start: 1,
            line_end: None,
            snippet: format!("Directory nesting depth: {depth}; threshold is {threshold}."),
        }],
        workspace_package: None,
        docs_url: None,
        provenance: Default::default(),
        risk: Default::default(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::scan::facts::FileFacts;

    #[test]
    fn ignores_rule_fixtures_when_calculating_deepest_path() {
        let facts = ScanFacts {
            root_path: PathBuf::from("."),
            files: vec![
                file_facts_with_path("./src/domain/user.ts"),
                file_facts_with_path(
                    "./tests/fixtures/rules/security.secret-candidate/true_positive_env_value/src/config.ts",
                ),
            ],
            ..ScanFacts::default()
        };

        let findings = audit_with_depth(&facts, 5);

        assert!(
            findings.is_empty(),
            "rule fixture paths should not become production architecture findings"
        );
    }

    #[test]
    fn ignores_test_paths_even_when_they_are_deeper_than_source_paths() {
        let facts = ScanFacts {
            root_path: PathBuf::from("."),
            files: vec![
                file_facts_with_path("./src/service.ts"),
                file_facts_with_path("./tests/unit/a/b/c/d/e/service.test.ts"),
            ],
            ..ScanFacts::default()
        };

        let findings = audit_with_depth(&facts, 5);

        assert!(
            findings.is_empty(),
            "test paths are allowed to be deeper than production source paths"
        );
    }

    #[test]
    fn ignores_docs_examples_generated_vendor_and_build_paths() {
        let facts = ScanFacts {
            root_path: PathBuf::from("."),
            files: vec![
                file_facts_with_path("./docs/reference/api/v1/generated/client/config.ts"),
                file_facts_with_path("./examples/react-native/deep/sample/src/App.tsx"),
                file_facts_with_path("./src/generated/openapi/client/v1/types.generated.ts"),
                file_facts_with_path("./vendor/company/package/deep/source/file.ts"),
                file_facts_with_path("./target/debug/build/package/out/generated.rs"),
                file_facts_with_path("./dist/assets/js/chunks/deep/file.js"),
            ],
            ..ScanFacts::default()
        };

        let findings = audit_with_depth(&facts, 5);

        assert!(findings.is_empty());
    }

    #[test]
    fn reports_deep_production_path() {
        let production_path = "./src/a/b/c/d/e/f/handler.ts";
        let facts = ScanFacts {
            root_path: PathBuf::from("."),
            files: vec![file_facts_with_path(production_path)],
            ..ScanFacts::default()
        };

        let findings = audit_with_depth(&facts, 5);

        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].rule_id, "architecture.deep-directory-nesting");
        assert_eq!(findings[0].evidence[0].path, PathBuf::from(production_path));
    }

    #[test]
    fn depth_is_measured_relative_to_the_scan_root() {
        // A repository that happens to sit deep on disk (e.g. an absolute scan
        // path) must not inflate depth: only the path below the scan root counts.
        let root = "/var/folders/tmp/repopilot-eval-abcdef";
        let facts = ScanFacts {
            root_path: PathBuf::from(root),
            files: vec![
                file_facts_with_path(&format!("{root}/src/a/b/handler.ts")),
                file_facts_with_path(&format!("{root}/src/a/b/c/d/e/handler.ts")),
            ],
            ..ScanFacts::default()
        };

        let findings = audit_with_depth(&facts, 5);

        assert_eq!(findings.len(), 1);
        assert_eq!(
            findings[0].evidence[0].path,
            PathBuf::from(format!("{root}/src/a/b/c/d/e/handler.ts"))
        );
    }

    fn audit_with_depth(facts: &ScanFacts, max_directory_depth: usize) -> Vec<Finding> {
        let audit = DeepDirectoryNestingAudit;
        let config = ScanConfig {
            max_directory_depth,
            ..ScanConfig::default()
        };

        audit.audit(facts, &config)
    }

    fn file_facts_with_path(path: &str) -> FileFacts {
        FileFacts {
            path: PathBuf::from(path),
            language: Some("TypeScript".to_string()),
            non_empty_lines: 1,
            branch_count: 0,
            imports: Vec::new(),
            content: Some("".to_string()),
            has_inline_tests: false,
        }
    }
}