diffctx 1.12.1

Selects the minimum code an LLM needs to review a git diff: walks the dependency graph outward from changed lines and stops when extra context stops paying for itself
Documentation
use std::path::{Path, PathBuf};

use once_cell::sync::Lazy;
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};

use crate::config::edge_weights::CICD;
use crate::types::Fragment;

use super::super::EdgeDict;
use super::super::base::{self, EdgeBuilder, FragmentIndex, add_edge, link_by_name};

static GHA_RUN_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?m)^\s{0,20}-?\s{0,5}run:\s{0,5}[|>]?\s{0,5}([^\n]{1,500})").unwrap()
});
static GHA_RUN_BLOCK_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?m)run:\s*[|>]-?\s*\n((?:\s{2,}[^\n]*\n?)+)").unwrap());

static GITLAB_SCRIPT_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?m)^\s{0,20}(?:script|before_script|after_script):\s?\n((?:\s+-[^\n]*\n)+)")
        .unwrap()
});
static GITLAB_INCLUDE_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r##"(?m)^\s{0,20}-?\s{0,5}(?:local|project|remote|template):\s{0,5}['"]?([^'"#\n]{1,300})"##)
        .unwrap()
});

static JENKINS_SH_DOUBLE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"(?m)sh\s*\(?"([^"]*)""#).unwrap());
static JENKINS_SH_SINGLE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?m)sh\s*\(?'([^']*)'").unwrap());
static JENKINS_SCRIPT_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?ms)script\s*\{([^}]+)\}").unwrap());

static SCRIPT_CALL_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(
        r"\b(?:python3|python|dotnet|gradle|pytest|cargo|pnpm|yarn|bash|make|mypy|node|ruff|npm|mvn|go|sh)\s+([^\s;&|]+)",
    )
    .unwrap()
});

static FILE_REF_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(
        r"(?:\./|scripts/|bin/|tools/|src/|tests/)([a-zA-Z0-9_.-]+(?:\.(?:sh|py|js|ts|rb))?)",
    )
    .unwrap()
});

static PKG_MANAGER_SUBCOMMANDS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
    [
        "run", "test", "start", "build", "install", "ci", "init", "publish", "pack", "link",
        "unlink", "exec", "add", "remove", "upgrade", "info", "list", "outdated", "audit", "fix",
        "cache", "config", "help", "version", "create", "set", "get", "why", "dedupe", "prune",
        "dlx", "store",
    ]
    .iter()
    .copied()
    .collect()
});

fn is_github_actions(path: &Path) -> bool {
    let path_str = path.to_string_lossy();
    let lower = path_str.to_lowercase();
    (lower.contains(".github") && lower.contains("workflows"))
        && (lower.ends_with(".yml") || lower.ends_with(".yaml"))
}

fn is_gitlab_ci(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == ".gitlab-ci.yml" || name == ".gitlab-ci.yaml" || name.starts_with("gitlab-ci")
}

fn is_jenkinsfile(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == "jenkinsfile" || name.ends_with(".jenkinsfile") || name.ends_with(".jenkins")
}

fn is_circleci(path: &Path) -> bool {
    let path_str = path.to_string_lossy().to_lowercase();
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    path_str.contains(".circleci") && (name == "config.yml" || name == "config.yaml")
}

fn is_travis(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == ".travis.yml" || name == ".travis.yaml"
}

fn is_azure_pipelines(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == "azure-pipelines.yml"
        || name == "azure-pipelines.yaml"
        || name.starts_with("azure-pipeline")
}

fn is_tox(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == "tox.ini"
}

fn is_nox(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == "noxfile.py"
}

fn is_ci_file(path: &Path) -> bool {
    is_github_actions(path)
        || is_gitlab_ci(path)
        || is_jenkinsfile(path)
        || is_circleci(path)
        || is_travis(path)
        || is_azure_pipelines(path)
        || is_tox(path)
        || is_nox(path)
}

fn extract_script_refs(content: &str) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();

    for cap in SCRIPT_CALL_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            let script = m.as_str().trim().trim_matches(|c| c == '\'' || c == '"');
            if !script.is_empty()
                && !script.starts_with('-')
                && !PKG_MANAGER_SUBCOMMANDS.contains(script.to_lowercase().as_str())
            {
                refs.insert(script.to_string());
            }
        }
    }

    for cap in FILE_REF_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.insert(m.as_str().to_string());
        }
    }

    refs
}

fn extract_gha_refs(content: &str) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();

    for cap in GHA_RUN_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.extend(extract_script_refs(m.as_str()));
        }
    }

    for cap in GHA_RUN_BLOCK_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.extend(extract_script_refs(m.as_str()));
        }
    }

    refs
}

fn extract_gitlab_refs(content: &str) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();

    for cap in GITLAB_SCRIPT_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.extend(extract_script_refs(m.as_str()));
        }
    }

    for cap in GITLAB_INCLUDE_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.insert(m.as_str().trim().to_string());
        }
    }

    refs
}

fn extract_jenkins_refs(content: &str) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();

    for cap in JENKINS_SH_DOUBLE_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.extend(extract_script_refs(m.as_str()));
        }
    }

    for cap in JENKINS_SH_SINGLE_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.extend(extract_script_refs(m.as_str()));
        }
    }

    for cap in JENKINS_SCRIPT_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            refs.extend(extract_script_refs(m.as_str()));
        }
    }

    refs
}

fn extract_refs_for_content(path: &Path, content: &str) -> FxHashSet<String> {
    if is_github_actions(path) {
        extract_gha_refs(content)
    } else if is_gitlab_ci(path) {
        extract_gitlab_refs(content)
    } else if is_jenkinsfile(path) {
        extract_jenkins_refs(content)
    } else {
        extract_script_refs(content)
    }
}

fn add_package_json_ref(content: &str, refs: &mut FxHashSet<String>) {
    let lower = content.to_lowercase();
    if lower.contains("npm") || lower.contains("yarn") || lower.contains("pnpm") {
        refs.insert("package.json".to_string());
    }
}

pub struct CICDEdgeBuilder;

impl EdgeBuilder for CICDEdgeBuilder {
    fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
        let ci_frags: Vec<&Fragment> = fragments
            .iter()
            .filter(|f| is_ci_file(Path::new(f.path())))
            .collect();
        if ci_frags.is_empty() {
            return EdgeDict::default();
        }

        let idx = FragmentIndex::new(fragments, repo_root);
        let mut edges = EdgeDict::default();

        for ci in &ci_frags {
            let refs = extract_refs_for_content(Path::new(ci.path()), &ci.content);
            for r in &refs {
                link_by_name(
                    &ci.id,
                    r,
                    &idx,
                    &mut edges,
                    CICD.script_weight,
                    CICD.reverse_factor,
                );
            }

            let lower = ci.content.to_lowercase();
            if lower.contains("npm")
                || lower.contains("yarn")
                || lower.contains("pnpm")
                || lower.contains("npx")
            {
                for f in fragments {
                    let fname = Path::new(f.path())
                        .file_name()
                        .map(|n| n.to_string_lossy().to_lowercase())
                        .unwrap_or_default();
                    if fname == "package.json" && f.id != ci.id {
                        add_edge(
                            &mut edges,
                            &ci.id,
                            &f.id,
                            CICD.weight * CICD.script_modifier,
                            CICD.reverse_factor,
                        );
                    }
                }
            }
        }

        edges
    }

    fn discover_related_files(
        &self,
        changed: &[PathBuf],
        candidates: &[PathBuf],
        repo_root: Option<&Path>,
        file_cache: Option<&FxHashMap<PathBuf, String>>,
    ) -> Vec<PathBuf> {
        let ci_files: Vec<&PathBuf> = changed.iter().filter(|p| is_ci_file(p)).collect();
        if ci_files.is_empty() {
            return vec![];
        }

        let mut refs = FxHashSet::default();

        for ci in &ci_files {
            let content = match base::read_file_cached(ci, file_cache) {
                Some(c) => c,
                None => continue,
            };

            let local_refs = extract_refs_for_content(ci, &content);
            refs.extend(local_refs);
            add_package_json_ref(&content, &mut refs);
        }

        base::discover_files_by_refs(&refs, changed, candidates, repo_root)
    }
}