1use std::collections::HashMap;
2use std::time::Duration;
3
4use ought_spec::{Clause, Keyword, Section, SpecGraph, Temporal};
5
6use crate::types::{AuditFinding, AuditFindingKind, AuditResult};
7
8pub fn audit(specs: &SpecGraph) -> anyhow::Result<AuditResult> {
14 let mut findings: Vec<AuditFinding> = Vec::new();
15
16 let mut all_clauses: Vec<ClauseWithContext> = Vec::new();
18 for spec in specs.specs() {
19 collect_clauses_with_context(&spec.sections, &spec.name, &mut all_clauses);
20 }
21
22 check_deadline_conflicts(&all_clauses, &mut findings);
24
25 check_invariant_conflicts(&all_clauses, &mut findings);
27
28 check_missing_otherwise(&all_clauses, &mut findings);
30
31 check_redundancy(&all_clauses, &mut findings);
33
34 check_given_overlaps(&all_clauses, &mut findings);
36
37 Ok(AuditResult { findings })
38}
39
40struct ClauseWithContext {
42 clause: Clause,
43 #[allow(dead_code)]
44 section_path: String,
45}
46
47fn collect_clauses_with_context(
48 sections: &[Section],
49 parent_path: &str,
50 out: &mut Vec<ClauseWithContext>,
51) {
52 for section in sections {
53 let section_path = format!("{} > {}", parent_path, section.title);
54 for clause in §ion.clauses {
55 if clause.pending {
60 continue;
61 }
62 out.push(ClauseWithContext {
63 clause: clause.clone(),
64 section_path: section_path.clone(),
65 });
66 for ow in &clause.otherwise {
68 out.push(ClauseWithContext {
69 clause: ow.clone(),
70 section_path: section_path.clone(),
71 });
72 }
73 }
74 collect_clauses_with_context(§ion.subsections, §ion_path, out);
75 }
76}
77
78fn check_deadline_conflicts(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
81 let deadline_clauses: Vec<(&ClauseWithContext, Duration)> = clauses
82 .iter()
83 .filter_map(|c| {
84 if let Some(Temporal::Deadline(dur)) = &c.clause.temporal {
85 Some((c, *dur))
86 } else {
87 None
88 }
89 })
90 .collect();
91
92 for i in 0..deadline_clauses.len() {
95 for j in (i + 1)..deadline_clauses.len() {
96 let (a, dur_a) = &deadline_clauses[i];
97 let (b, dur_b) = &deadline_clauses[j];
98
99 let a_text_lower = a.clause.text.to_lowercase();
101 let b_text_lower = b.clause.text.to_lowercase();
102
103 let a_mentions_b = clause_text_overlaps(&a_text_lower, &b_text_lower);
105 let b_mentions_a = clause_text_overlaps(&b_text_lower, &a_text_lower);
106
107 if a_mentions_b && dur_a < dur_b {
108 findings.push(AuditFinding {
109 kind: AuditFindingKind::Contradiction,
110 description: format!(
111 "{} MUST BY {:?} but references {} MUST BY {:?} -- sub-operation deadline exceeds parent deadline",
112 a.clause.text, dur_a, b.clause.text, dur_b
113 ),
114 clauses: vec![a.clause.id.clone(), b.clause.id.clone()],
115 suggestion: Some(format!(
116 "Reduce the sub-operation deadline below {:?} or increase the parent deadline",
117 dur_a
118 )),
119 confidence: Some(0.85),
120 });
121 } else if b_mentions_a && dur_b < dur_a {
122 findings.push(AuditFinding {
123 kind: AuditFindingKind::Contradiction,
124 description: format!(
125 "{} MUST BY {:?} but references {} MUST BY {:?} -- sub-operation deadline exceeds parent deadline",
126 b.clause.text, dur_b, a.clause.text, dur_a
127 ),
128 clauses: vec![b.clause.id.clone(), a.clause.id.clone()],
129 suggestion: Some(format!(
130 "Reduce the sub-operation deadline below {:?} or increase the parent deadline",
131 dur_b
132 )),
133 confidence: Some(0.85),
134 });
135 }
136 }
137 }
138}
139
140fn clause_text_overlaps(a: &str, b: &str) -> bool {
142 let a_words: Vec<&str> = a.split_whitespace().filter(|w| w.len() > 3).collect();
143 let b_words: Vec<&str> = b.split_whitespace().filter(|w| w.len() > 3).collect();
144 let overlap = a_words.iter().filter(|w| b_words.contains(w)).count();
145 overlap >= 2
146}
147
148fn check_invariant_conflicts(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
150 let invariant_clauses: Vec<&ClauseWithContext> = clauses
151 .iter()
152 .filter(|c| matches!(c.clause.temporal, Some(Temporal::Invariant)))
153 .collect();
154
155 for i in 0..invariant_clauses.len() {
156 for j in (i + 1)..invariant_clauses.len() {
157 let a = invariant_clauses[i];
158 let b = invariant_clauses[j];
159
160 let contradicts = is_contradictory(&a.clause, &b.clause);
162 if contradicts {
163 findings.push(AuditFinding {
164 kind: AuditFindingKind::Contradiction,
165 description: format!(
166 "MUST ALWAYS {} conflicts with MUST ALWAYS {}",
167 a.clause.text, b.clause.text
168 ),
169 clauses: vec![a.clause.id.clone(), b.clause.id.clone()],
170 suggestion: Some(
171 "Reconcile invariants by choosing one consistent model".to_string(),
172 ),
173 confidence: Some(0.80),
174 });
175 }
176 }
177 }
178}
179
180fn is_contradictory(a: &Clause, b: &Clause) -> bool {
182 let a_positive = matches!(
184 a.keyword,
185 Keyword::Must | Keyword::Should | Keyword::MustAlways | Keyword::MustBy
186 );
187 let b_positive = matches!(
188 b.keyword,
189 Keyword::Must | Keyword::Should | Keyword::MustAlways | Keyword::MustBy
190 );
191 let a_negative = matches!(a.keyword, Keyword::MustNot | Keyword::ShouldNot | Keyword::Wont);
192 let b_negative = matches!(b.keyword, Keyword::MustNot | Keyword::ShouldNot | Keyword::Wont);
193
194 if (a_positive && b_negative) || (a_negative && b_positive) {
196 let overlap = clause_text_overlaps(
197 &a.text.to_lowercase(),
198 &b.text.to_lowercase(),
199 );
200 if overlap {
201 return true;
202 }
203 }
204
205 let a_lower = a.text.to_lowercase();
207 let b_lower = b.text.to_lowercase();
208
209 let opposites = [
210 ("single", "multiple"),
211 ("one", "many"),
212 ("exactly one", "concurrent"),
213 ("block", "allow"),
214 ("deny", "permit"),
215 ("reject", "accept"),
216 ("disable", "enable"),
217 ("synchronous", "asynchronous"),
218 ];
219
220 for (pos, neg) in &opposites {
221 if (a_lower.contains(pos) && b_lower.contains(neg))
222 || (a_lower.contains(neg) && b_lower.contains(pos))
223 {
224 if clause_text_overlaps(&a_lower, &b_lower) {
226 return true;
227 }
228 }
229 }
230
231 false
232}
233
234fn check_missing_otherwise(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
236 let network_hints = [
237 "request", "api", "fetch", "remote", "server", "http", "endpoint", "network", "download",
238 "upload", "connect", "socket", "tcp", "udp", "grpc", "webhook",
239 ];
240
241 for c in clauses {
242 if c.clause.keyword != Keyword::Must && c.clause.keyword != Keyword::MustBy {
243 continue;
244 }
245 if !c.clause.otherwise.is_empty() {
246 continue;
247 }
248 let text_lower = c.clause.text.to_lowercase();
249 let is_network = network_hints.iter().any(|h| text_lower.contains(h));
250 if is_network {
251 findings.push(AuditFinding {
252 kind: AuditFindingKind::Gap,
253 description: format!(
254 "MUST {} has no OTHERWISE fallback but appears to depend on network/remote operations",
255 c.clause.text
256 ),
257 clauses: vec![c.clause.id.clone()],
258 suggestion: Some(
259 "Add an OTHERWISE clause specifying fallback behavior when the network operation fails"
260 .to_string(),
261 ),
262 confidence: Some(0.75),
263 });
264 }
265 }
266}
267
268fn check_redundancy(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
270 for i in 0..clauses.len() {
271 for j in (i + 1)..clauses.len() {
272 let a = &clauses[i];
273 let b = &clauses[j];
274
275 if a.clause.keyword == Keyword::Otherwise || b.clause.keyword == Keyword::Otherwise {
277 continue;
278 }
279
280 let similarity = text_similarity(&a.clause.text, &b.clause.text);
281 if similarity > 0.8 && a.clause.keyword == b.clause.keyword {
282 findings.push(AuditFinding {
283 kind: AuditFindingKind::Redundancy,
284 description: format!(
285 "Clauses appear to express the same obligation: \"{}\" and \"{}\"",
286 a.clause.text, b.clause.text
287 ),
288 clauses: vec![a.clause.id.clone(), b.clause.id.clone()],
289 suggestion: Some("Consider merging these clauses into one".to_string()),
290 confidence: Some(similarity),
291 });
292 }
293 }
294 }
295}
296
297fn text_similarity(a: &str, b: &str) -> f64 {
299 let a_owned: std::collections::HashSet<String> = a
300 .to_lowercase()
301 .split_whitespace()
302 .filter(|w| w.len() > 2)
303 .map(|w| w.to_string())
304 .collect();
305 let b_owned: std::collections::HashSet<String> = b
306 .to_lowercase()
307 .split_whitespace()
308 .filter(|w| w.len() > 2)
309 .map(|w| w.to_string())
310 .collect();
311
312 if a_owned.is_empty() && b_owned.is_empty() {
313 return 1.0;
314 }
315
316 let intersection = a_owned.intersection(&b_owned).count();
317 let union = a_owned.union(&b_owned).count();
318
319 if union == 0 {
320 return 0.0;
321 }
322
323 intersection as f64 / union as f64
324}
325
326fn check_given_overlaps(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
328 let mut given_groups: HashMap<String, Vec<&ClauseWithContext>> = HashMap::new();
330
331 for c in clauses {
332 if let Some(ref condition) = c.clause.condition {
333 given_groups
334 .entry(condition.to_lowercase())
335 .or_default()
336 .push(c);
337 }
338 }
339
340 let conditions: Vec<String> = given_groups.keys().cloned().collect();
343 for i in 0..conditions.len() {
344 for j in (i + 1)..conditions.len() {
345 let cond_a = &conditions[i];
346 let cond_b = &conditions[j];
347
348 if clause_text_overlaps(cond_a, cond_b) {
350 let clauses_a = &given_groups[cond_a];
351 let clauses_b = &given_groups[cond_b];
352
353 for ca in clauses_a {
354 for cb in clauses_b {
355 if is_contradictory(&ca.clause, &cb.clause) {
356 findings.push(AuditFinding {
357 kind: AuditFindingKind::Contradiction,
358 description: format!(
359 "GIVEN {} ({}) overlaps with GIVEN {} ({}) -- contradictory obligations",
360 cond_a, ca.clause.text, cond_b, cb.clause.text
361 ),
362 clauses: vec![ca.clause.id.clone(), cb.clause.id.clone()],
363 suggestion: Some(
364 "Make GIVEN conditions mutually exclusive or add explicit precedence rules"
365 .to_string(),
366 ),
367 confidence: Some(0.70),
368 });
369 }
370 }
371 }
372 }
373 }
374 }
375}