bctx-weave 0.1.7

bctx-weave — FilterMesh lens pipeline, CLI interception, domain compression
Documentation
use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;

// golangci-lint offense: "path/to/file.go:10:5: message (linter-name)"
static OFFENSE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"^([^:]+:\d+:\d+): (.+) \(([a-zA-Z0-9_/-]+)\)$").unwrap());
// "level [linter-name] message" format (text reporter)
static LEVEL_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"^(WARN|ERROR|INFO)\s+\[([a-zA-Z0-9_/-]+)\]").unwrap());
// golangci-lint progress/info lines
static PROGRESS_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?m)^(level=info|time=|msg=|Finished linters|Running linters)[^\n]*\n?").unwrap()
});
// unified diff lines from --fix output
static DIFF_HUNK_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?m)^(\+\+\+|---|@@|[ +\-])[^\n]*\n?").unwrap());
// run duration / summary lines
static SUMMARY_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(Run duration|Issues:|linters used|linters enabled|linters disabled)[^\n]*")
        .unwrap()
});

// Maps linter names to their supercategory for grouping
fn linter_category(name: &str) -> &'static str {
    match name {
        "govet" | "vet" => "govet",
        "errcheck" => "errcheck",
        "staticcheck" | "SA1000" | "SA1001" | "SA4006" | "SA9003" => "staticcheck",
        "stylecheck" | "ST1000" | "ST1003" | "ST1020" | "ST1021" | "ST1023" => "stylecheck",
        "gocritic" => "gocritic",
        _ => "other",
    }
}

pub fn compress_golangci(raw: &str, exit_code: i32) -> String {
    let cleaned = compactor::normalise(raw);

    // JSON output mode: look for {"Issues":[ or [{"FromLinter":
    if cleaned.trim_start().starts_with('[') && cleaned.contains("\"FromLinter\"")
        || cleaned.contains("\"Issues\"")
    {
        return compress_golangci_json(&cleaned, exit_code);
    }

    // --fix mode: unified diff present
    if cleaned.contains("--- a/") || cleaned.contains("+++ b/") {
        return compress_golangci_fix(&cleaned, exit_code);
    }

    let s = PROGRESS_RE.replace_all(&cleaned, "");

    if exit_code == 0 {
        return "golangci-lint: no issues found".to_string();
    }

    // Collect summary lines (run duration, issue counts)
    let mut summaries: Vec<String> = Vec::new();
    for m in SUMMARY_RE.find_iter(&s) {
        summaries.push(m.as_str().to_string());
    }

    // Group offenses by linter name
    let mut linter_counts: HashMap<String, usize> = HashMap::new();
    let mut error_lines: Vec<String> = Vec::new();

    for line in s.lines() {
        let t = line.trim();
        if t.is_empty() {
            continue;
        }
        if SUMMARY_RE.is_match(t) {
            continue;
        }

        if let Some(caps) = OFFENSE_RE.captures(t) {
            let linter = caps[3].to_string();
            *linter_counts.entry(linter).or_insert(0) += 1;
            continue;
        }
        if let Some(caps) = LEVEL_RE.captures(t) {
            let linter = caps[2].to_string();
            *linter_counts.entry(linter).or_insert(0) += 1;
            continue;
        }
        if t.starts_with("FAIL")
            || t.contains("issue")
            || t.contains("error")
            || t.starts_with("level=error")
        {
            error_lines.push(line.to_string());
        }
    }

    // Group by supercategory
    let mut by_category: HashMap<&str, Vec<(String, usize)>> = HashMap::new();
    for (linter, count) in &linter_counts {
        let cat = linter_category(linter);
        by_category
            .entry(cat)
            .or_default()
            .push((linter.clone(), *count));
    }

    let mut grouped: Vec<String> = Vec::new();
    let mut categories: Vec<&&str> = by_category.keys().collect();
    categories.sort();
    for cat in categories {
        let linters = &by_category[cat];
        if linters.len() == 1 {
            let (name, count) = &linters[0];
            if *count > 1 {
                grouped.push(format!("{name}{count})"));
            } else {
                grouped.push(name.clone());
            }
        } else {
            let total: usize = linters.iter().map(|(_, c)| c).sum();
            let names: Vec<String> = {
                let mut v: Vec<_> = linters.iter().collect();
                v.sort_by_key(|(n, _)| n.clone());
                v.iter()
                    .map(|(n, c)| {
                        if *c > 1 {
                            format!("{n}{c})")
                        } else {
                            n.clone()
                        }
                    })
                    .collect()
            };
            grouped.push(format!("[{cat}] {total} issues — {}", names.join(", ")));
        }
    }
    grouped.sort();

    let mut result = summaries;
    result.extend(grouped);
    result.extend(error_lines);
    compactor::collapse_blanks(&result.join("\n"))
}

fn compress_golangci_json(raw: &str, exit_code: i32) -> String {
    if exit_code == 0 {
        return "golangci-lint: no issues found".to_string();
    }
    // Extract key fields: FromLinter, Text, Pos.Filename + Line
    let mut counts: HashMap<String, usize> = HashMap::new();
    // Simple field extraction without pulling in serde_json
    let linter_re = Regex::new(r#""FromLinter"\s*:\s*"([^"]+)""#).expect("regex compile");
    for cap in linter_re.captures_iter(raw) {
        *counts.entry(cap[1].to_string()).or_insert(0) += 1;
    }
    if counts.is_empty() {
        return compactor::collapse_blanks(raw);
    }
    let total: usize = counts.values().sum();
    let mut parts: Vec<String> = counts
        .iter()
        .map(|(k, v)| {
            if *v > 1 {
                format!("{k}{v})")
            } else {
                k.clone()
            }
        })
        .collect();
    parts.sort();
    format!(
        "golangci-lint [json]: {total} issues — {}",
        parts.join(", ")
    )
}

fn compress_golangci_fix(raw: &str, _exit_code: i32) -> String {
    // Count fixed files from diff headers
    let file_re = Regex::new(r"--- a/([^\s]+)").expect("regex compile");
    let files: Vec<&str> = file_re
        .captures_iter(raw)
        .filter_map(|c| c.get(1).map(|m| m.as_str()))
        .collect();
    if files.is_empty() {
        return DIFF_HUNK_RE.replace_all(raw, "").to_string();
    }
    let unique: std::collections::HashSet<&str> = files.iter().copied().collect();
    format!(
        "golangci-lint --fix: {} fixes across {} files",
        files.len(),
        unique.len()
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn success_returns_clean_message() {
        let raw =
            "level=info msg=\"Running linters...\"\nlevel=info msg=\"Finished linters in 2.1s\"\n";
        let out = compress_golangci(raw, 0);
        assert!(out.contains("no issues"), "{out}");
    }

    #[test]
    fn groups_repeated_linter_offenses() {
        let raw =
            (0..5)
                .map(|i| format!("pkg/foo.go:{i}:1: use of unsafe.Pointer (govet)\n"))
                .chain((0..3).map(|i| {
                    format!("pkg/bar.go:{i}:1: exported function lacks comment (golint)\n")
                }))
                .collect::<String>();
        let out = compress_golangci(&raw, 1);
        assert!(out.contains("×5") || out.contains("(×5)"), "govet: {out}");
        assert!(out.contains("×3") || out.contains("(×3)"), "golint: {out}");
    }

    #[test]
    fn strips_progress_info_lines() {
        let raw = "level=info msg=\"Running linters...\"\ntime=\"12:00\" level=info msg=\"Starting analysis\"\npkg/a.go:1:1: something wrong (staticcheck)\n";
        let out = compress_golangci(raw, 1);
        assert!(!out.contains("Running linters"), "{out}");
        assert!(!out.contains("Starting analysis"), "{out}");
    }

    #[test]
    fn json_mode_extracts_linter_counts() {
        let raw = r#"[{"FromLinter":"govet","Text":"printf arg mismatch"},{"FromLinter":"govet","Text":"another issue"},{"FromLinter":"errcheck","Text":"unchecked error"}]"#;
        let out = compress_golangci(raw, 1);
        assert!(out.contains("json"), "{out}");
        assert!(out.contains("govet"), "{out}");
        assert!(out.contains("×2"), "{out}");
    }

    #[test]
    fn fix_mode_summarises_diff() {
        let raw = "--- a/pkg/foo.go\n+++ b/pkg/foo.go\n@@ -1,3 +1,3 @@\n-bad\n+good\n--- a/pkg/bar.go\n+++ b/pkg/bar.go\n@@ -5,1 +5,1 @@\n-old\n+new\n";
        let out = compress_golangci(raw, 0);
        assert!(out.contains("fix") || out.contains("fixes"), "{out}");
    }

    #[test]
    fn category_grouping_staticcheck() {
        let raw = (0..4)
            .map(|i| format!("pkg/a.go:{i}:1: unused var (staticcheck)\n"))
            .chain((0..2).map(|i| format!("pkg/b.go:{i}:1: style issue (stylecheck)\n")))
            .collect::<String>();
        let out = compress_golangci(&raw, 1);
        assert!(out.contains("staticcheck") || out.contains("×4"), "{out}");
    }

    #[test]
    fn exit_zero_always_clean() {
        let raw = "pkg/a.go:1:1: something (govet)\n";
        let out = compress_golangci(raw, 0);
        assert!(out.contains("no issues"), "{out}");
    }
}