Skip to main content

lean_ctx/core/contextops/
lint.rs

1use serde::{Deserialize, Serialize};
2
3use super::config::RulesConfig;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub enum LintSeverity {
7    Error,
8    Warning,
9    Info,
10}
11
12impl std::fmt::Display for LintSeverity {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        match self {
15            Self::Error => write!(f, "ERROR"),
16            Self::Warning => write!(f, "WARNING"),
17            Self::Info => write!(f, "INFO"),
18        }
19    }
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct LintWarning {
24    pub severity: LintSeverity,
25    pub code: String,
26    pub message: String,
27    pub target: Option<String>,
28}
29
30const KNOWN_TOOLS: &[&str] = &[
31    "ctx_read",
32    "ctx_shell",
33    "ctx_search",
34    "ctx_tree",
35    "ctx_compress",
36    "ctx_edit",
37    "ctx_overview",
38    "ctx_session",
39    "ctx_knowledge",
40    "ctx_semantic_search",
41    "ctx_benchmark",
42    "ctx_workflow",
43    "ctx_heatmap",
44    "ctx_cost",
45    "ctx_metrics",
46    "ctx_call",
47    "ctx_callgraph",
48    "ctx_gain",
49    "ctx_provider",
50    "ctx_pack",
51    "ctx_review",
52    "ctx_multi_read",
53    "ctx_graph",
54    "ctx_plugins",
55    "ctx_repomap",
56    "ctx_rules",
57    "ctx_multi_repo",
58    "ctx_agent",
59    "ctx_dedup",
60    "ctx_preload",
61];
62
63const REQUIRED_SECTIONS: &[&str] = &["Mode Selection", "File Editing"];
64
65pub fn lint(config: &RulesConfig, home: &std::path::Path) -> Vec<LintWarning> {
66    let mut warnings = Vec::new();
67
68    lint_core_content(&config.rules.core.content, &mut warnings);
69    lint_version(&config.rules.version, &mut warnings);
70    lint_agent_consistency(config, &mut warnings);
71    lint_targets(home, &mut warnings);
72
73    warnings
74}
75
76fn lint_core_content(content: &str, warnings: &mut Vec<LintWarning>) {
77    if content.trim().is_empty() {
78        warnings.push(LintWarning {
79            severity: LintSeverity::Error,
80            code: "EMPTY_CORE".to_string(),
81            message: "Core rules content is empty".to_string(),
82            target: None,
83        });
84        return;
85    }
86
87    for section in REQUIRED_SECTIONS {
88        if !content.contains(section) {
89            warnings.push(LintWarning {
90                severity: LintSeverity::Warning,
91                code: "MISSING_SECTION".to_string(),
92                message: format!("Core rules missing required section: {section}"),
93                target: None,
94            });
95        }
96    }
97
98    check_tool_references(content, None, warnings);
99}
100
101fn lint_version(version: &str, warnings: &mut Vec<LintWarning>) {
102    if version.is_empty() {
103        warnings.push(LintWarning {
104            severity: LintSeverity::Error,
105            code: "NO_VERSION".to_string(),
106            message: "Rules version is not set".to_string(),
107            target: None,
108        });
109    }
110
111    let expected = crate::rules_inject::RULES_VERSION_STR;
112    if !expected.contains(version) && !version.contains("1.0") {
113        warnings.push(LintWarning {
114            severity: LintSeverity::Info,
115            code: "VERSION_MISMATCH".to_string(),
116            message: format!(
117                "Config version '{version}' does not match current rules version '{expected}'"
118            ),
119            target: None,
120        });
121    }
122}
123
124fn lint_agent_consistency(config: &RulesConfig, warnings: &mut Vec<LintWarning>) {
125    for (agent_name, agent_rules) in &config.rules.agent {
126        check_tool_references(&agent_rules.extra, Some(agent_name), warnings);
127
128        if agent_rules.extra.contains("NEVER") && config.rules.core.content.contains("ALWAYS") {
129            let never_lines: Vec<&str> = agent_rules
130                .extra
131                .lines()
132                .filter(|l| l.contains("NEVER"))
133                .collect();
134            let always_lines: Vec<&str> = config
135                .rules
136                .core
137                .content
138                .lines()
139                .filter(|l| l.contains("ALWAYS"))
140                .collect();
141
142            for never_line in &never_lines {
143                for always_line in &always_lines {
144                    if lines_reference_same_tool(never_line, always_line) {
145                        warnings.push(LintWarning {
146                            severity: LintSeverity::Warning,
147                            code: "CONFLICT".to_string(),
148                            message: format!(
149                                "Agent '{agent_name}' has NEVER rule that may conflict with core ALWAYS rule"
150                            ),
151                            target: Some(agent_name.clone()),
152                        });
153                        break;
154                    }
155                }
156            }
157        }
158    }
159}
160
161fn lint_targets(home: &std::path::Path, warnings: &mut Vec<LintWarning>) {
162    let statuses = crate::rules_inject::collect_rules_status(home);
163    for status in &statuses {
164        if status.detected && status.state == "outdated" {
165            warnings.push(LintWarning {
166                severity: LintSeverity::Warning,
167                code: "OUTDATED_TARGET".to_string(),
168                message: format!("{} has outdated rules (version mismatch)", status.name),
169                target: Some(status.name.clone()),
170            });
171        }
172    }
173}
174
175fn check_tool_references(content: &str, agent: Option<&str>, warnings: &mut Vec<LintWarning>) {
176    for word in content.split_whitespace() {
177        let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
178        if cleaned.starts_with("ctx_") && !KNOWN_TOOLS.contains(&cleaned) {
179            warnings.push(LintWarning {
180                severity: LintSeverity::Warning,
181                code: "UNKNOWN_TOOL".to_string(),
182                message: format!("References unknown tool: {cleaned}"),
183                target: agent.map(String::from),
184            });
185        }
186    }
187}
188
189fn lines_reference_same_tool(line_a: &str, line_b: &str) -> bool {
190    for tool in KNOWN_TOOLS {
191        if line_a.contains(tool) && line_b.contains(tool) {
192            return true;
193        }
194    }
195    false
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::core::contextops::config::{AgentRules, CoreRules, RulesSection};
202
203    fn make_config(core_content: &str) -> RulesConfig {
204        RulesConfig {
205            rules: RulesSection {
206                version: "1.0".to_string(),
207                core: CoreRules {
208                    content: core_content.to_string(),
209                },
210                agent: std::collections::HashMap::new(),
211            },
212        }
213    }
214
215    #[test]
216    fn lint_severity_display() {
217        assert_eq!(LintSeverity::Error.to_string(), "ERROR");
218        assert_eq!(LintSeverity::Warning.to_string(), "WARNING");
219        assert_eq!(LintSeverity::Info.to_string(), "INFO");
220    }
221
222    #[test]
223    fn lint_empty_core() {
224        let config = make_config("");
225        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
226        let warnings = lint(&config, &home);
227        assert!(warnings.iter().any(|w| w.code == "EMPTY_CORE"));
228    }
229
230    #[test]
231    fn lint_missing_sections() {
232        let config = make_config("some rules without required sections");
233        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
234        let warnings = lint(&config, &home);
235        let missing: Vec<_> = warnings
236            .iter()
237            .filter(|w| w.code == "MISSING_SECTION")
238            .collect();
239        assert!(!missing.is_empty());
240    }
241
242    #[test]
243    fn lint_unknown_tool() {
244        let config = make_config("## Mode Selection\n## File Editing\nUse ctx_nonexistent_tool");
245        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
246        let warnings = lint(&config, &home);
247        assert!(warnings.iter().any(|w| w.code == "UNKNOWN_TOOL"));
248    }
249
250    #[test]
251    fn lint_known_tools_pass() {
252        let config = make_config("## Mode Selection\n## File Editing\nUse ctx_read and ctx_shell");
253        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
254        let warnings = lint(&config, &home);
255        assert!(!warnings.iter().any(|w| w.code == "UNKNOWN_TOOL"));
256    }
257
258    #[test]
259    fn lint_conflict_detection() {
260        let mut config = make_config("## Mode Selection\n## File Editing\nALWAYS use ctx_read");
261        config.rules.agent.insert(
262            "test_agent".to_string(),
263            AgentRules {
264                extra: "NEVER use ctx_read".to_string(),
265            },
266        );
267        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
268        let warnings = lint(&config, &home);
269        assert!(warnings.iter().any(|w| w.code == "CONFLICT"));
270    }
271
272    #[test]
273    fn lint_no_version() {
274        let mut config = make_config("## Mode Selection\n## File Editing\nrules");
275        config.rules.version = String::new();
276        let home = std::path::PathBuf::from("/tmp/fake_lint_test");
277        let warnings = lint(&config, &home);
278        assert!(warnings.iter().any(|w| w.code == "NO_VERSION"));
279    }
280
281    #[test]
282    fn lines_reference_same_tool_true() {
283        assert!(lines_reference_same_tool(
284            "NEVER use ctx_read for context",
285            "ALWAYS use ctx_read for editing"
286        ));
287    }
288
289    #[test]
290    fn lines_reference_same_tool_false() {
291        assert!(!lines_reference_same_tool(
292            "NEVER use ctx_read",
293            "ALWAYS use ctx_shell"
294        ));
295    }
296}