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