lean_ctx/core/contextops/
lint.rs1use 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}