1use std::collections::HashMap;
9
10use crate::compare::{ArtifactChange, RepoState, RepositoryComparison, CHANGE_ADDED, CHANGE_MODIFIED, CHANGE_REMOVED};
11use crate::parse::Artifact;
12use crate::pycompat::{is_re_digit, is_re_word, py_strip};
13
14pub const SPECIFICITY_REGRESSION: &str = "specificity_regression";
16pub const AMBIGUITY_INTRODUCED: &str = "ambiguity_introduced";
17pub const CONSTRAINT_WEAKENED: &str = "constraint_weakened";
18pub const CONSTRAINT_REMOVED: &str = "constraint_removed";
19pub const ACCEPTANCE_CRITERIA_REMOVED: &str = "acceptance_criteria_removed";
20pub const SUCCESS_MEASURES_REMOVED: &str = "success_measures_removed";
21pub const RELATIONSHIP_IMPACT: &str = "relationship_impact";
22pub const UNLINKED_SCOPE: &str = "unlinked_scope";
23
24pub const SEVERITY_WARNING: &str = "warning";
25pub const SEVERITY_INFO: &str = "info";
26
27const AMBIGUITY_TERMS: [&str; 10] = [
31 "easy",
32 "fast",
33 "flexible",
34 "intuitive",
35 "quickly",
36 "robust",
37 "scalable",
38 "seamless",
39 "simple",
40 "user-friendly",
41];
42const MANDATORY_TERMS: [&str; 2] = ["must", "shall"];
43const HEDGE_TERMS: [&str; 3] = ["should", "may", "could"];
44
45const ACCEPTANCE_SECTIONS: [&str; 1] = ["acceptance criteria"];
47const SUCCESS_SECTIONS: [&str; 2] = ["success measures", "success metrics"];
48
49#[derive(Debug, Clone)]
51pub struct IntentFinding {
52 pub code: &'static str,
53 pub severity: &'static str, pub path: String, pub identifier: Option<String>,
56 pub detail: String, pub evidence: Vec<String>,
58}
59
60fn has_token(text: &str, token: &str) -> bool {
65 let chars: Vec<char> = text.chars().collect();
66 let tok: Vec<char> = token.chars().collect();
67 let (n, m) = (chars.len(), tok.len());
68 if m == 0 || m > n {
69 return false;
70 }
71 for i in 0..=(n - m) {
72 if i > 0 && is_re_word(chars[i - 1]) {
73 continue; }
75 let matched = (0..m).all(|k| {
76 let tc = chars[i + k];
77 let wc = tok[k];
78 tc == wc || (tc.is_ascii_alphabetic() && tc.to_ascii_lowercase() == wc)
79 });
80 if matched && (i + m == n || !is_re_word(chars[i + m])) {
81 return true;
82 }
83 }
84 false
85}
86
87fn ambiguous_terms(text: &str) -> Vec<&'static str> {
89 AMBIGUITY_TERMS
90 .iter()
91 .copied()
92 .filter(|term| has_token(text, term))
93 .collect()
94}
95
96fn has_digit(text: &str) -> bool {
97 text.chars().any(is_re_digit)
98}
99
100fn has_mandatory(text: &str) -> bool {
101 MANDATORY_TERMS.iter().any(|t| has_token(text, t))
102}
103
104fn has_hedge(text: &str) -> bool {
105 HEDGE_TERMS.iter().any(|t| has_token(text, t))
106}
107
108fn section_filled(artifact: &Artifact, headings: &[&str]) -> bool {
109 headings
110 .iter()
111 .any(|h| !py_strip(artifact.section(h).unwrap_or("")).is_empty())
112}
113
114fn quoted_join(terms: &[&str]) -> String {
115 terms
116 .iter()
117 .map(|t| format!("'{t}'"))
118 .collect::<Vec<_>>()
119 .join(", ")
120}
121
122fn modified_findings(
123 change: &ArtifactChange,
124 base: &Artifact,
125 head: &Artifact,
126) -> Vec<IntentFinding> {
127 let mut findings: Vec<IntentFinding> = Vec::new();
128 let empty = crate::diff::Diff::default();
129 let diff = change.diff.as_ref().unwrap_or(&empty);
130
131 for req_change in &diff.modified_requirements {
132 let evidence = vec![
133 format!("- {}", req_change.old_text),
134 format!("+ {}", req_change.new_text),
135 ];
136 if has_digit(&req_change.old_text) && !has_digit(&req_change.new_text) {
137 findings.push(IntentFinding {
138 code: SPECIFICITY_REGRESSION,
139 severity: SEVERITY_WARNING,
140 path: change.path.clone(),
141 identifier: change.id.clone(),
142 detail: format!("Measurable requirement {} became vague.", req_change.id),
143 evidence: evidence.clone(),
144 });
145 }
146 let new_terms: Vec<&str> = ambiguous_terms(&req_change.new_text)
147 .into_iter()
148 .filter(|term| !has_token(&req_change.old_text, term))
149 .collect();
150 if !new_terms.is_empty() {
151 findings.push(IntentFinding {
152 code: AMBIGUITY_INTRODUCED,
153 severity: SEVERITY_WARNING,
154 path: change.path.clone(),
155 identifier: change.id.clone(),
156 detail: format!(
157 "Ambiguous wording introduced in {}: {}.",
158 req_change.id,
159 quoted_join(&new_terms)
160 ),
161 evidence: evidence.clone(),
162 });
163 }
164 if has_mandatory(&req_change.old_text)
165 && !has_mandatory(&req_change.new_text)
166 && has_hedge(&req_change.new_text)
167 {
168 findings.push(IntentFinding {
169 code: CONSTRAINT_WEAKENED,
170 severity: SEVERITY_WARNING,
171 path: change.path.clone(),
172 identifier: change.id.clone(),
173 detail: format!(
174 "Mandatory requirement {} weakened to hedged wording.",
175 req_change.id
176 ),
177 evidence,
178 });
179 }
180 }
181
182 for removed in &diff.removed_requirements {
183 if has_mandatory(&removed.text) {
184 findings.push(IntentFinding {
185 code: CONSTRAINT_REMOVED,
186 severity: SEVERITY_WARNING,
187 path: change.path.clone(),
188 identifier: change.id.clone(),
189 detail: format!("Requirement {} with mandatory wording removed.", removed.id),
190 evidence: vec![format!("- {}", removed.text)],
191 });
192 }
193 }
194
195 if section_filled(base, &ACCEPTANCE_SECTIONS) && !section_filled(head, &ACCEPTANCE_SECTIONS) {
196 findings.push(IntentFinding {
197 code: ACCEPTANCE_CRITERIA_REMOVED,
198 severity: SEVERITY_WARNING,
199 path: change.path.clone(),
200 identifier: change.id.clone(),
201 detail: "Acceptance criteria section removed.".to_string(),
202 evidence: Vec::new(),
203 });
204 }
205 if section_filled(base, &SUCCESS_SECTIONS) && !section_filled(head, &SUCCESS_SECTIONS) {
206 findings.push(IntentFinding {
207 code: SUCCESS_MEASURES_REMOVED,
208 severity: SEVERITY_WARNING,
209 path: change.path.clone(),
210 identifier: change.id.clone(),
211 detail: "Success measures section removed.".to_string(),
212 evidence: Vec::new(),
213 });
214 }
215
216 findings
217}
218
219fn removed_findings(change: &ArtifactChange, base: &RepoState) -> Vec<IntentFinding> {
220 let mut findings = Vec::new();
221 if let Some(entry) = base.entry(&change.path) {
222 for requirement in &entry.artifact.product.requirements {
223 if has_mandatory(&requirement.text) {
224 findings.push(IntentFinding {
225 code: CONSTRAINT_REMOVED,
226 severity: SEVERITY_WARNING,
227 path: change.path.clone(),
228 identifier: change.id.clone(),
229 detail: format!(
230 "Requirement {} with mandatory wording removed.",
231 requirement.id
232 ),
233 evidence: vec![format!("- {}", requirement.text)],
234 });
235 }
236 }
237 }
238 findings
239}
240
241fn added_findings(change: &ArtifactChange, head: &RepoState) -> Vec<IntentFinding> {
242 let mut findings = Vec::new();
243 if let Some(entry) = head.entry(&change.path) {
244 for requirement in &entry.artifact.product.requirements {
245 let terms = ambiguous_terms(&requirement.text);
246 if !terms.is_empty() {
247 findings.push(IntentFinding {
248 code: AMBIGUITY_INTRODUCED,
249 severity: SEVERITY_WARNING,
250 path: change.path.clone(),
251 identifier: change.id.clone(),
252 detail: format!(
253 "Ambiguous wording introduced in {}: {}.",
254 requirement.id,
255 quoted_join(&terms)
256 ),
257 evidence: vec![format!("+ {}", requirement.text)],
258 });
259 }
260 }
261 }
262 findings
263}
264
265fn reference_maps(state: &RepoState) -> (HashMap<String, Vec<String>>, Vec<String>) {
268 let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
269 let mut outgoing: Vec<String> = Vec::new();
270 for relationship in &state.relationships {
271 let source_rel = state.rel_of(&relationship.source_path);
272 if !outgoing.contains(&source_rel) {
273 outgoing.push(source_rel.clone());
274 }
275 if let Some(resolved) = &relationship.resolved_path {
276 let target_rel = state.rel_of(resolved);
277 let source_id = state
278 .entry(&source_rel)
279 .map(|e| e.info.id.clone())
280 .unwrap_or_else(|| source_rel.clone());
281 incoming.entry(target_rel).or_default().push(source_id);
282 }
283 }
284 (incoming, outgoing)
285}
286
287fn impact_finding(
288 change: &ArtifactChange,
289 incoming: &HashMap<String, Vec<String>>,
290 verb: &str,
291) -> Option<IntentFinding> {
292 let mut sources: Vec<String> = incoming.get(&change.path).cloned().unwrap_or_default();
293 sources.sort();
294 sources.dedup();
295 if sources.is_empty() {
296 return None;
297 }
298 Some(IntentFinding {
299 code: RELATIONSHIP_IMPACT,
300 severity: SEVERITY_INFO,
301 path: change.path.clone(),
302 identifier: change.id.clone(),
303 detail: format!(
304 "{verb} artifact is referenced by {} artifact(s).",
305 sources.len()
306 ),
307 evidence: sources,
308 })
309}
310
311pub fn analyze_intent(comparison: &RepositoryComparison) -> Vec<IntentFinding> {
313 let mut findings: Vec<IntentFinding> = Vec::new();
314 let (base_incoming, _) = reference_maps(&comparison.base);
315 let (head_incoming, head_outgoing) = reference_maps(&comparison.head);
316
317 for change in &comparison.changes {
318 if change.change == CHANGE_MODIFIED {
319 let base_product = comparison.base.entry(&change.path).map(|e| &e.artifact);
320 let head_product = comparison.head.entry(&change.path).map(|e| &e.artifact);
321 if let (Some(base_product), Some(head_product)) = (base_product, head_product) {
322 findings.extend(modified_findings(change, base_product, head_product));
323 }
324 if let Some(impact) = impact_finding(change, &head_incoming, "Modified") {
325 findings.push(impact);
326 }
327 } else if change.change == CHANGE_REMOVED {
328 findings.extend(removed_findings(change, &comparison.base));
329 if let Some(impact) = impact_finding(change, &base_incoming, "Removed") {
330 findings.push(impact);
331 }
332 } else if change.change == CHANGE_ADDED {
333 findings.extend(added_findings(change, &comparison.head));
334 if change.type_name != "unknown"
335 && !head_outgoing.contains(&change.path)
336 && !head_incoming.contains_key(&change.path)
337 {
338 findings.push(IntentFinding {
339 code: UNLINKED_SCOPE,
340 severity: SEVERITY_WARNING,
341 path: change.path.clone(),
342 identifier: change.id.clone(),
343 detail: "New artifact declares no relationships and nothing references it."
344 .to_string(),
345 evidence: Vec::new(),
346 });
347 }
348 }
349 }
350
351 findings.sort_by(|a, b| {
352 (a.severity != SEVERITY_WARNING, a.code, &a.path, &a.detail).cmp(&(
353 b.severity != SEVERITY_WARNING,
354 b.code,
355 &b.path,
356 &b.detail,
357 ))
358 });
359 findings
360}