ai-slop 0.1.16

Lints bug reports, commit messages, changelogs, and other outbound text for AI slop
Documentation
//! structural fingerprint family: SLOP-X001 boilerplate skeleton,
//! SLOP-X002 bullet-heavy commit body, SLOP-X003 oversized body,
//! SLOP-X004 over-structured short doc.

use crate::engine::{CompiledPolicy, Hit};
use crate::extract::Doc;
use crate::input::{line_ranges, FormatData, Prepared};
use crate::Config;

pub const HANDLED: &[&str] = &["SLOP-X001", "SLOP-X002", "SLOP-X003", "SLOP-X004"];

pub fn evaluate(
    cp: &CompiledPolicy,
    prepared: &Prepared,
    doc: &Doc,
    config: &Config,
    hits: &mut Vec<Hit>,
) {
    if let Some(idx) = super::active(cp, config, "SLOP-X001") {
        let rule = &cp.pkg.rules[idx];
        let set: Vec<String> = rule
            .params
            .as_table()
            .and_then(|t| t.get("heading_set"))
            .and_then(|v| v.as_array())
            .map(|a| {
                a.iter()
                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
                    .collect()
            })
            .unwrap_or_default();
        let min = super::param_i64(rule, "min_matches").unwrap_or(3) as usize;
        let matching: Vec<&crate::extract::Heading> = doc
            .headings
            .iter()
            .filter(|h| set.iter().any(|s| s == &h.text.to_ascii_lowercase()))
            .collect();
        if matching.len() >= min {
            hits.push(Hit::new(idx, matching[0].range.clone()));
        }
    }

    if let Some(idx) = super::active(cp, config, "SLOP-X002") {
        if let FormatData::Commit(split) = &prepared.format {
            let rule = &cp.pkg.rules[idx];
            let min_lines = super::param_i64(rule, "min_body_lines").unwrap_or(10) as usize;
            let min_pct = super::param_i64(rule, "min_bullet_line_pct").unwrap_or(50) as usize;
            let body = &prepared.text[split.body.clone()];
            let mut lines = 0usize;
            let mut bullet_lines = 0usize;
            for lr in line_ranges(body) {
                let line = body[lr].trim_start();
                if line.is_empty() {
                    continue;
                }
                lines += 1;
                if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ") {
                    bullet_lines += 1;
                }
            }
            if lines >= min_lines && bullet_lines * 100 >= min_pct * lines {
                hits.push(Hit::new(idx, split.body.clone()));
            }
        }
    }

    if let Some(idx) = super::active(cp, config, "SLOP-X003") {
        let rule = &cp.pkg.rules[idx];
        let cap = rule
            .params
            .as_table()
            .and_then(|t| t.get("max_words"))
            .and_then(|v| v.as_table())
            .and_then(|t| t.get(config.profile.as_str()))
            .and_then(|v| v.as_integer());
        if let Some(cap) = cap {
            if doc.stats.word_count > cap as u64 {
                hits.push(Hit::new(idx, super::document_anchor(&prepared.text)));
            }
        }
    }

    if let Some(idx) = super::active(cp, config, "SLOP-X004") {
        let rule = &cp.pkg.rules[idx];
        let max_words = super::param_i64(rule, "max_words").unwrap_or(300) as u64;
        let min_headings = super::param_i64(rule, "min_headings").unwrap_or(4) as usize;
        if doc.stats.word_count <= max_words
            && doc.stats.word_count > 0
            && doc.headings.len() >= min_headings
            && !x004_heading_set_exempt(rule, doc, config)
        {
            hits.push(Hit::new(idx, doc.headings[0].range.clone()));
        }
    }
}

/// SLOP-X004 `exempt_heading_sets`: true when the profile has an entry and
/// the document's headings after the first (the title, per SLOP-K001),
/// lowercased, equal one listed set exactly and in order. An extra, missing,
/// or reordered heading fails the match, so the exemption covers only the
/// declared template.
fn x004_heading_set_exempt(rule: &crate::policy::Rule, doc: &Doc, config: &Config) -> bool {
    let Some(sets) = rule
        .params
        .as_table()
        .and_then(|t| t.get("exempt_heading_sets"))
        .and_then(|v| v.as_table())
        .and_then(|t| t.get(config.profile.as_str()))
        .and_then(|v| v.as_array())
    else {
        return false;
    };
    let got: Vec<String> = doc
        .headings
        .iter()
        .skip(1)
        .map(|h| h.text.trim().to_lowercase())
        .collect();
    sets.iter().any(|set| match set.as_array() {
        Some(want) => {
            want.len() == got.len()
                && want
                    .iter()
                    .zip(&got)
                    .all(|(w, g)| w.as_str() == Some(g.as_str()))
        }
        None => false,
    })
}