lean_ctx/core/
context_lint.rs1use crate::core::tokens::count_tokens;
19
20const RETEACH_PATTERNS: &[&str] = &[
24 "as you know",
25 "please note",
26 "keep in mind",
27 "it is important to",
28 "needless to say",
29 "obviously,",
30 "completes faster than",
31 "as an example",
32];
33
34const TOOL_DESC_TOKEN_BUDGET: usize = 80;
36
37const MIN_SIGNIFICANT_LINE_CHARS: usize = 24;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Severity {
44 Error,
46 Warn,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum LintKind {
53 DuplicateLine,
55 ReTeaching,
57 DuplicateToolDescription,
59 VerboseToolDescription,
61}
62
63#[derive(Debug, Clone)]
65pub struct LintFinding {
66 pub severity: Severity,
67 pub kind: LintKind,
68 pub source: String,
70 pub detail: String,
71}
72
73impl LintFinding {
74 #[must_use]
76 pub fn is_error(&self) -> bool {
77 self.severity == Severity::Error
78 }
79}
80
81fn is_content_line(line: &str) -> bool {
83 let t = line.trim();
84 !t.is_empty() && !t.starts_with("<!--") && !t.starts_with('#')
85}
86
87#[must_use]
90pub fn lint_rules_text(source: &str, text: &str) -> Vec<LintFinding> {
91 let mut findings = Vec::new();
92 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
93 for raw in text.lines() {
94 if !is_content_line(raw) {
95 continue;
96 }
97 let line = raw.trim();
98 let lower = line.to_lowercase();
99 for pat in RETEACH_PATTERNS {
100 if lower.contains(pat) {
101 findings.push(LintFinding {
102 severity: Severity::Error,
103 kind: LintKind::ReTeaching,
104 source: source.to_string(),
105 detail: format!("low-signal re-teaching phrase {pat:?}: {line}"),
106 });
107 }
108 }
109 if line.chars().count() >= MIN_SIGNIFICANT_LINE_CHARS && !seen.insert(lower) {
110 findings.push(LintFinding {
111 severity: Severity::Error,
112 kind: LintKind::DuplicateLine,
113 source: source.to_string(),
114 detail: format!("duplicate content line: {line}"),
115 });
116 }
117 }
118 findings
119}
120
121#[must_use]
124pub fn lint_tool_descriptions(tools: &[rmcp::model::Tool]) -> Vec<LintFinding> {
125 let mut findings = Vec::new();
126 let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
127 for t in tools {
128 let desc = t.description.as_deref().unwrap_or("").trim().to_string();
129 if desc.is_empty() {
130 continue;
131 }
132 let tokens = count_tokens(&desc);
133 if tokens > TOOL_DESC_TOKEN_BUDGET {
134 findings.push(LintFinding {
135 severity: Severity::Warn,
136 kind: LintKind::VerboseToolDescription,
137 source: format!("tool:{}", t.name),
138 detail: format!(
139 "{tokens} tok description — trim to when/why + the non-obvious gotcha (budget {TOOL_DESC_TOKEN_BUDGET})"
140 ),
141 });
142 }
143 if let Some(prev) = seen.insert(desc.to_lowercase(), t.name.to_string()) {
144 findings.push(LintFinding {
145 severity: Severity::Error,
146 kind: LintKind::DuplicateToolDescription,
147 source: format!("tool:{}", t.name),
148 detail: format!("byte-identical description to tool `{prev}`"),
149 });
150 }
151 }
152 findings
153}
154
155#[must_use]
157pub fn lint_injected_context() -> Vec<LintFinding> {
158 let mut findings = lint_rules_text("rules", &crate::rules_inject::canonical_rules_block());
159 let tools = crate::server::tool_visibility::advertised_tool_defs_default();
160 findings.extend(lint_tool_descriptions(&tools));
161 findings
162}
163
164#[must_use]
166pub fn error_count(findings: &[LintFinding]) -> usize {
167 findings.iter().filter(|f| f.is_error()).count()
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn reteaching_phrase_is_an_error() {
176 let findings = lint_rules_text("t", "As you know, the cache stores compressed reads here.");
177 assert!(
178 findings
179 .iter()
180 .any(|f| f.kind == LintKind::ReTeaching && f.is_error())
181 );
182 }
183
184 #[test]
185 fn exact_duplicate_line_is_an_error() {
186 let text =
187 "prefer ctx_search over native grep always\nprefer ctx_search over native grep always";
188 let findings = lint_rules_text("t", text);
189 assert!(
190 findings
191 .iter()
192 .any(|f| f.kind == LintKind::DuplicateLine && f.is_error())
193 );
194 }
195
196 #[test]
197 fn terse_high_signal_text_has_no_errors() {
198 let text = "• Read/cat -> ctx_read(path, mode)\n• Grep -> ctx_search(pattern, path)";
199 assert_eq!(error_count(&lint_rules_text("t", text)), 0);
200 }
201
202 #[test]
203 fn structural_lines_are_skipped() {
204 let text = "<!-- lean-ctx-rules -->\n\n# header\n<!-- lean-ctx-rules -->\n\n# header";
206 assert_eq!(error_count(&lint_rules_text("t", text)), 0);
207 }
208
209 #[test]
213 fn live_injected_context_has_no_error_findings() {
214 let _iso = crate::core::data_dir::isolated_data_dir();
215 let findings = lint_injected_context();
216 let errors: Vec<&LintFinding> = findings.iter().filter(|f| f.is_error()).collect();
217 assert!(
218 errors.is_empty(),
219 "injected context must be high-signal, found Error findings: {errors:#?}"
220 );
221 }
222}