lean-ctx 3.7.1

Context Runtime for AI Agents with CCP. 63 MCP tools, 10 read modes, 60+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
use serde::{Deserialize, Serialize};

use super::config::RulesConfig;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LintSeverity {
    Error,
    Warning,
    Info,
}

impl std::fmt::Display for LintSeverity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Error => write!(f, "ERROR"),
            Self::Warning => write!(f, "WARNING"),
            Self::Info => write!(f, "INFO"),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LintWarning {
    pub severity: LintSeverity,
    pub code: String,
    pub message: String,
    pub target: Option<String>,
}

const KNOWN_TOOLS: &[&str] = &[
    "ctx_read",
    "ctx_shell",
    "ctx_search",
    "ctx_tree",
    "ctx_compress",
    "ctx_edit",
    "ctx_overview",
    "ctx_session",
    "ctx_knowledge",
    "ctx_semantic_search",
    "ctx_benchmark",
    "ctx_workflow",
    "ctx_heatmap",
    "ctx_cost",
    "ctx_metrics",
    "ctx_call",
    "ctx_callgraph",
    "ctx_gain",
    "ctx_provider",
    "ctx_pack",
    "ctx_review",
    "ctx_multi_read",
    "ctx_graph",
    "ctx_plugins",
    "ctx_repomap",
    "ctx_rules",
    "ctx_multi_repo",
    "ctx_agent",
    "ctx_dedup",
    "ctx_preload",
];

const REQUIRED_SECTIONS: &[&str] = &["Mode Selection", "File Editing"];

pub fn lint(config: &RulesConfig, home: &std::path::Path) -> Vec<LintWarning> {
    let mut warnings = Vec::new();

    lint_core_content(&config.rules.core.content, &mut warnings);
    lint_version(&config.rules.version, &mut warnings);
    lint_agent_consistency(config, &mut warnings);
    lint_targets(home, &mut warnings);

    warnings
}

fn lint_core_content(content: &str, warnings: &mut Vec<LintWarning>) {
    if content.trim().is_empty() {
        warnings.push(LintWarning {
            severity: LintSeverity::Error,
            code: "EMPTY_CORE".to_string(),
            message: "Core rules content is empty".to_string(),
            target: None,
        });
        return;
    }

    for section in REQUIRED_SECTIONS {
        if !content.contains(section) {
            warnings.push(LintWarning {
                severity: LintSeverity::Warning,
                code: "MISSING_SECTION".to_string(),
                message: format!("Core rules missing required section: {section}"),
                target: None,
            });
        }
    }

    check_tool_references(content, None, warnings);
}

fn lint_version(version: &str, warnings: &mut Vec<LintWarning>) {
    if version.is_empty() {
        warnings.push(LintWarning {
            severity: LintSeverity::Error,
            code: "NO_VERSION".to_string(),
            message: "Rules version is not set".to_string(),
            target: None,
        });
    }

    let expected = crate::rules_inject::RULES_VERSION_STR;
    if !expected.contains(version) && !version.contains("1.0") {
        warnings.push(LintWarning {
            severity: LintSeverity::Info,
            code: "VERSION_MISMATCH".to_string(),
            message: format!(
                "Config version '{version}' does not match current rules version '{expected}'"
            ),
            target: None,
        });
    }
}

fn lint_agent_consistency(config: &RulesConfig, warnings: &mut Vec<LintWarning>) {
    for (agent_name, agent_rules) in &config.rules.agent {
        check_tool_references(&agent_rules.extra, Some(agent_name), warnings);

        if agent_rules.extra.contains("NEVER") && config.rules.core.content.contains("ALWAYS") {
            let never_lines: Vec<&str> = agent_rules
                .extra
                .lines()
                .filter(|l| l.contains("NEVER"))
                .collect();
            let always_lines: Vec<&str> = config
                .rules
                .core
                .content
                .lines()
                .filter(|l| l.contains("ALWAYS"))
                .collect();

            for never_line in &never_lines {
                for always_line in &always_lines {
                    if lines_reference_same_tool(never_line, always_line) {
                        warnings.push(LintWarning {
                            severity: LintSeverity::Warning,
                            code: "CONFLICT".to_string(),
                            message: format!(
                                "Agent '{agent_name}' has NEVER rule that may conflict with core ALWAYS rule"
                            ),
                            target: Some(agent_name.clone()),
                        });
                        break;
                    }
                }
            }
        }
    }
}

fn lint_targets(home: &std::path::Path, warnings: &mut Vec<LintWarning>) {
    let statuses = crate::rules_inject::collect_rules_status(home);
    for status in &statuses {
        if status.detected && status.state == "outdated" {
            warnings.push(LintWarning {
                severity: LintSeverity::Warning,
                code: "OUTDATED_TARGET".to_string(),
                message: format!("{} has outdated rules (version mismatch)", status.name),
                target: Some(status.name.clone()),
            });
        }
    }
}

fn check_tool_references(content: &str, agent: Option<&str>, warnings: &mut Vec<LintWarning>) {
    for word in content.split_whitespace() {
        let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
        if cleaned.starts_with("ctx_") && !KNOWN_TOOLS.contains(&cleaned) {
            warnings.push(LintWarning {
                severity: LintSeverity::Warning,
                code: "UNKNOWN_TOOL".to_string(),
                message: format!("References unknown tool: {cleaned}"),
                target: agent.map(String::from),
            });
        }
    }
}

fn lines_reference_same_tool(line_a: &str, line_b: &str) -> bool {
    for tool in KNOWN_TOOLS {
        if line_a.contains(tool) && line_b.contains(tool) {
            return true;
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::contextops::config::{AgentRules, CoreRules, RulesSection};

    fn make_config(core_content: &str) -> RulesConfig {
        RulesConfig {
            rules: RulesSection {
                version: "1.0".to_string(),
                core: CoreRules {
                    content: core_content.to_string(),
                },
                agent: std::collections::HashMap::new(),
            },
        }
    }

    #[test]
    fn lint_severity_display() {
        assert_eq!(LintSeverity::Error.to_string(), "ERROR");
        assert_eq!(LintSeverity::Warning.to_string(), "WARNING");
        assert_eq!(LintSeverity::Info.to_string(), "INFO");
    }

    #[test]
    fn lint_empty_core() {
        let config = make_config("");
        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
        let warnings = lint(&config, &home);
        assert!(warnings.iter().any(|w| w.code == "EMPTY_CORE"));
    }

    #[test]
    fn lint_missing_sections() {
        let config = make_config("some rules without required sections");
        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
        let warnings = lint(&config, &home);
        let missing: Vec<_> = warnings
            .iter()
            .filter(|w| w.code == "MISSING_SECTION")
            .collect();
        assert!(!missing.is_empty());
    }

    #[test]
    fn lint_unknown_tool() {
        let config = make_config("## Mode Selection\n## File Editing\nUse ctx_nonexistent_tool");
        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
        let warnings = lint(&config, &home);
        assert!(warnings.iter().any(|w| w.code == "UNKNOWN_TOOL"));
    }

    #[test]
    fn lint_known_tools_pass() {
        let config = make_config("## Mode Selection\n## File Editing\nUse ctx_read and ctx_shell");
        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
        let warnings = lint(&config, &home);
        assert!(!warnings.iter().any(|w| w.code == "UNKNOWN_TOOL"));
    }

    #[test]
    fn lint_conflict_detection() {
        let mut config = make_config("## Mode Selection\n## File Editing\nALWAYS use ctx_read");
        config.rules.agent.insert(
            "test_agent".to_string(),
            AgentRules {
                extra: "NEVER use ctx_read".to_string(),
            },
        );
        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
        let warnings = lint(&config, &home);
        assert!(warnings.iter().any(|w| w.code == "CONFLICT"));
    }

    #[test]
    fn lint_no_version() {
        let mut config = make_config("## Mode Selection\n## File Editing\nrules");
        config.rules.version = String::new();
        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
        let warnings = lint(&config, &home);
        assert!(warnings.iter().any(|w| w.code == "NO_VERSION"));
    }

    #[test]
    fn lines_reference_same_tool_true() {
        assert!(lines_reference_same_tool(
            "NEVER use ctx_read for context",
            "ALWAYS use ctx_read for editing"
        ));
    }

    #[test]
    fn lines_reference_same_tool_false() {
        assert!(!lines_reference_same_tool(
            "NEVER use ctx_read",
            "ALWAYS use ctx_shell"
        ));
    }
}