Skip to main content

kardo_core/analysis/
consistency.rs

1//! Cross-config consistency analysis.
2//!
3//! Detects contradictory instructions between CLAUDE.md, AGENTS.md,
4//! .cursor/rules/*.mdc, copilot-instructions.md, and .windsurfrules.
5//!
6//! Example contradiction: CLAUDE.md says "ALWAYS use tabs" but
7//! copilot-instructions.md says "ALWAYS use spaces".
8
9/// A detected contradiction between two config files.
10#[derive(Debug, Clone)]
11pub struct Contradiction {
12    /// First config file involved in the contradiction.
13    pub file_a: String,
14    /// Second config file involved in the contradiction.
15    pub file_b: String,
16    /// Topic of the contradiction (e.g. "indentation", "semicolons").
17    pub topic: String,
18    /// How severe the contradiction is.
19    pub severity: ContradictionSeverity,
20    /// Line excerpt from file_a containing the conflicting instruction.
21    pub excerpt_a: String,
22    /// Line excerpt from file_b containing the conflicting instruction.
23    pub excerpt_b: String,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum ContradictionSeverity {
28    /// Definite contradiction (opposite keywords on same topic)
29    High,
30    /// Possible inconsistency (different styles on same topic)
31    Medium,
32    /// Minor variation (different levels on same topic)
33    Low,
34}
35
36/// Result of consistency analysis.
37#[derive(Debug, Clone)]
38pub struct ConsistencyResult {
39    /// Penalty to apply to the total score (0.0 = no penalty, max 0.15).
40    pub penalty: f64,
41    /// List of detected contradictions.
42    pub contradictions: Vec<Contradiction>,
43    /// Number of files analyzed.
44    pub files_checked: usize,
45}
46
47/// Input: a config file's name and content.
48pub struct ConfigEntry {
49    /// Config file name or relative path (e.g. "CLAUDE.md").
50    pub name: String,
51    /// Full text content of the config file.
52    pub content: String,
53}
54
55/// Detects contradictory instructions across multiple AI config files.
56pub struct ConsistencyAnalyzer;
57
58/// Antonym pairs to check for contradictions.
59/// Format: (topic, keyword_a_variants, keyword_b_variants)
60const ANTONYM_PAIRS: &[(&str, &[&str], &[&str])] = &[
61    ("indentation", &["use tabs", "tabs only", "tab indentation"], &["use spaces", "spaces only", "space indentation", "2 spaces", "4 spaces"]),
62    ("quotes", &["single quotes", "use '", "prefer '"], &["double quotes", "use \"", "prefer \""]),
63    ("semicolons", &["no semicolons", "omit semicolons", "without semicolons"], &["use semicolons", "add semicolons", "require semicolons"]),
64    ("type annotations", &["avoid types", "no typescript", "javascript only"], &["use typescript", "require types", "type everything", "strict types"]),
65    ("comments", &["avoid comments", "no comments", "don't comment"], &["add comments", "document everything", "always comment"]),
66    ("testing", &["no tests", "skip tests", "don't write tests"], &["always test", "write tests", "require tests", "tdd"]),
67    ("line length", &["80 characters", "80 char", "max 80"], &["120 characters", "120 char", "max 120", "100 characters", "max 100"]),
68    ("error handling", &["ignore errors", "suppress errors"], &["handle all errors", "never ignore errors", "always handle"]),
69    ("git commits", &["no commits", "don't commit"], &["always commit", "commit after each"]),
70    ("console output", &["no console.log", "remove console", "no logging"], &["use console.log", "log everything", "add logging"]),
71    ("imports", &["default imports", "import default"], &["named imports only", "no default imports", "named exports"]),
72    ("async", &["avoid async", "no async/await", "use callbacks"], &["use async/await", "always async", "prefer async"]),
73];
74
75impl ConsistencyAnalyzer {
76    /// Analyze consistency across multiple config files.
77    pub fn analyze(configs: &[ConfigEntry]) -> ConsistencyResult {
78        if configs.len() < 2 {
79            return ConsistencyResult {
80                penalty: 0.0,
81                contradictions: vec![],
82                files_checked: configs.len(),
83            };
84        }
85
86        let mut contradictions = Vec::new();
87
88        // Check each pair of configs
89        for i in 0..configs.len() {
90            for j in (i + 1)..configs.len() {
91                let file_a = &configs[i];
92                let file_b = &configs[j];
93
94                for &(topic, variants_a, variants_b) in ANTONYM_PAIRS {
95                    let found_a = Self::find_pattern(&file_a.content, variants_a);
96                    let found_b = Self::find_pattern(&file_b.content, variants_b);
97
98                    if let (Some(excerpt_a), Some(excerpt_b)) = (found_a, found_b) {
99                        // file_a says A, file_b says opposite B -> contradiction
100                        contradictions.push(Contradiction {
101                            file_a: file_a.name.clone(),
102                            file_b: file_b.name.clone(),
103                            topic: topic.to_string(),
104                            severity: ContradictionSeverity::High,
105                            excerpt_a,
106                            excerpt_b,
107                        });
108                        continue;
109                    }
110
111                    // Also check reversed (file_a says B, file_b says A)
112                    let found_b_in_a = Self::find_pattern(&file_a.content, variants_b);
113                    let found_a_in_b = Self::find_pattern(&file_b.content, variants_a);
114
115                    if let (Some(excerpt_a), Some(excerpt_b)) = (found_b_in_a, found_a_in_b) {
116                        contradictions.push(Contradiction {
117                            file_a: file_a.name.clone(),
118                            file_b: file_b.name.clone(),
119                            topic: topic.to_string(),
120                            severity: ContradictionSeverity::High,
121                            excerpt_a,
122                            excerpt_b,
123                        });
124                    }
125                }
126            }
127        }
128
129        // Deduplicate by topic + file pair (keep one per topic per file pair)
130        contradictions.dedup_by(|a, b| {
131            a.topic == b.topic && a.file_a == b.file_a && a.file_b == b.file_b
132        });
133
134        // Calculate penalty: max 15%
135        let penalty = if contradictions.is_empty() {
136            0.0
137        } else {
138            let high_count = contradictions
139                .iter()
140                .filter(|c| c.severity == ContradictionSeverity::High)
141                .count();
142            let med_count = contradictions
143                .iter()
144                .filter(|c| c.severity == ContradictionSeverity::Medium)
145                .count();
146            let raw_penalty = high_count as f64 * 0.04 + med_count as f64 * 0.02;
147            raw_penalty.min(0.15)
148        };
149
150        ConsistencyResult {
151            penalty,
152            contradictions,
153            files_checked: configs.len(),
154        }
155    }
156
157    /// Find if any of the given pattern variants appear in content.
158    /// Returns the matching line as an excerpt, or None.
159    fn find_pattern(content: &str, patterns: &[&str]) -> Option<String> {
160        let lower = content.to_lowercase();
161        for pattern in patterns {
162            if lower.contains(pattern) {
163                // Find the line containing the pattern
164                let excerpt = content
165                    .lines()
166                    .find(|l| l.to_lowercase().contains(pattern))
167                    .unwrap_or("")
168                    .trim()
169                    .to_string();
170                if !excerpt.is_empty() {
171                    return Some(excerpt);
172                }
173                return Some(format!("(contains: {})", pattern));
174            }
175        }
176        None
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_no_contradiction() {
186        let configs = vec![
187            ConfigEntry {
188                name: "CLAUDE.md".into(),
189                content: "ALWAYS use TypeScript. Use 2 spaces.".into(),
190            },
191            ConfigEntry {
192                name: ".windsurfrules".into(),
193                content: "Use TypeScript. Prefer 2 spaces.".into(),
194            },
195        ];
196        let result = ConsistencyAnalyzer::analyze(&configs);
197        assert_eq!(result.penalty, 0.0);
198    }
199
200    #[test]
201    fn test_tabs_vs_spaces_contradiction() {
202        let configs = vec![
203            ConfigEntry {
204                name: "CLAUDE.md".into(),
205                content: "ALWAYS use tabs for indentation.".into(),
206            },
207            ConfigEntry {
208                name: "copilot-instructions.md".into(),
209                content: "Use 2 spaces for indentation.".into(),
210            },
211        ];
212        let result = ConsistencyAnalyzer::analyze(&configs);
213        assert!(!result.contradictions.is_empty());
214        assert!(result.penalty > 0.0);
215        assert_eq!(result.contradictions[0].topic, "indentation");
216    }
217
218    #[test]
219    fn test_single_file_no_penalty() {
220        let configs = vec![ConfigEntry {
221            name: "CLAUDE.md".into(),
222            content: "Use tabs.".into(),
223        }];
224        let result = ConsistencyAnalyzer::analyze(&configs);
225        assert_eq!(result.penalty, 0.0);
226    }
227
228    #[test]
229    fn test_penalty_cap() {
230        // Many contradictions should be capped at 0.15
231        let configs = vec![
232            ConfigEntry {
233                name: "A.md".into(),
234                content: "use tabs. single quotes. no semicolons. no typescript. avoid comments. no tests. 80 characters. ignore errors.".into(),
235            },
236            ConfigEntry {
237                name: "B.md".into(),
238                content: "use spaces. double quotes. use semicolons. use typescript. add comments. always test. 120 characters. handle all errors.".into(),
239            },
240        ];
241        let result = ConsistencyAnalyzer::analyze(&configs);
242        assert!(
243            result.penalty <= 0.15,
244            "Penalty should be capped at 0.15, got {}",
245            result.penalty
246        );
247        assert!(!result.contradictions.is_empty());
248    }
249}