1#[derive(Debug, Clone)]
11pub struct Contradiction {
12 pub file_a: String,
14 pub file_b: String,
16 pub topic: String,
18 pub severity: ContradictionSeverity,
20 pub excerpt_a: String,
22 pub excerpt_b: String,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum ContradictionSeverity {
28 High,
30 Medium,
32 Low,
34}
35
36#[derive(Debug, Clone)]
38pub struct ConsistencyResult {
39 pub penalty: f64,
41 pub contradictions: Vec<Contradiction>,
43 pub files_checked: usize,
45}
46
47pub struct ConfigEntry {
49 pub name: String,
51 pub content: String,
53}
54
55pub struct ConsistencyAnalyzer;
57
58const 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 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 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 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 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 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 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 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 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 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}