Skip to main content

lean_ctx/core/
context_lint.rs

1//! Injected-context linter (#960) — keeps lean-ctx's OWN injected context
2//! (the rules block + advertised tool descriptions) high-signal.
3//!
4//! The Nisi/WorkOS "Case" talk is the motivation: comprehensive prose and
5//! re-teaching what a competent model already knows *degrades* reasoning and
6//! burns a finite attention budget; only the non-obvious gotcha earns its
7//! tokens. This linter encodes that discipline so it can be enforced in CI and
8//! surfaced in `doctor`.
9//!
10//! Two severities:
11//! * [`Severity::Error`] — exact-duplicate rule lines and low-signal re-teaching
12//!   phrases in the rules block. These ride *every* turn, are fully under our
13//!   control, and must fail the gate.
14//! * [`Severity::Warn`] — verbose or duplicated tool descriptions. Surfaced for
15//!   triage so the ~28-tool surface can be trimmed incrementally without
16//!   blocking unrelated work.
17
18use crate::core::tokens::count_tokens;
19
20/// Low-signal phrases that re-teach what a competent model already knows: they add
21/// per-turn tokens without changing behaviour, so they must never ride the rules
22/// block. Matched case-insensitively as substrings.
23const 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
34/// A tool description longer than this is comprehensive prose, not a gotcha (Warn).
35const TOOL_DESC_TOKEN_BUDGET: usize = 80;
36
37/// Lines shorter than this are treated as structural (headers, short labels) and
38/// skipped by duplicate detection.
39const MIN_SIGNIFICANT_LINE_CHARS: usize = 24;
40
41/// Whether a finding gates CI or is merely surfaced.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Severity {
44    /// Fails the CI gate.
45    Error,
46    /// Surfaced for triage, does not gate.
47    Warn,
48}
49
50/// The category of a lint finding.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum LintKind {
53    /// Two identical content lines in the rules block.
54    DuplicateLine,
55    /// A low-signal re-teaching phrase in the rules block.
56    ReTeaching,
57    /// Two tools advertise byte-identical descriptions.
58    DuplicateToolDescription,
59    /// A tool description exceeds the gotcha budget.
60    VerboseToolDescription,
61}
62
63/// One linter finding against the injected context.
64#[derive(Debug, Clone)]
65pub struct LintFinding {
66    pub severity: Severity,
67    pub kind: LintKind,
68    /// Where it was found (`"rules"` or `"tool:<name>"`).
69    pub source: String,
70    pub detail: String,
71}
72
73impl LintFinding {
74    /// Whether this finding gates CI.
75    #[must_use]
76    pub fn is_error(&self) -> bool {
77        self.severity == Severity::Error
78    }
79}
80
81/// A content line worth linting (skips blanks, HTML markers and `#` headers).
82fn is_content_line(line: &str) -> bool {
83    let t = line.trim();
84    !t.is_empty() && !t.starts_with("<!--") && !t.starts_with('#')
85}
86
87/// Lints a rules-block text for re-teaching phrases (Error) and exact-duplicate
88/// content lines (Error).
89#[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/// Lints advertised tool descriptions for byte-identical copies (Error) and
122/// gotcha-budget overruns (Warn).
123#[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/// Lints the live injected context this install would emit (rules + tool schemas).
156#[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/// Number of gating (Error) findings.
165#[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        // Markers, blanks and headers must not be flagged even if repeated.
205        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    /// The enforced gate: the live injected rules surface must carry zero Error
210    /// findings, proving the trim landed and guarding against future re-teaching
211    /// or duplication regressions (#960).
212    #[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}