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_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}