use std::collections::HashMap;
use crate::compare::{ArtifactChange, RepoState, RepositoryComparison, CHANGE_ADDED, CHANGE_MODIFIED, CHANGE_REMOVED};
use crate::parse::Artifact;
use crate::pycompat::{is_re_digit, is_re_word, py_strip};
pub const SPECIFICITY_REGRESSION: &str = "specificity_regression";
pub const AMBIGUITY_INTRODUCED: &str = "ambiguity_introduced";
pub const CONSTRAINT_WEAKENED: &str = "constraint_weakened";
pub const CONSTRAINT_REMOVED: &str = "constraint_removed";
pub const ACCEPTANCE_CRITERIA_REMOVED: &str = "acceptance_criteria_removed";
pub const SUCCESS_MEASURES_REMOVED: &str = "success_measures_removed";
pub const RELATIONSHIP_IMPACT: &str = "relationship_impact";
pub const UNLINKED_SCOPE: &str = "unlinked_scope";
pub const SEVERITY_WARNING: &str = "warning";
pub const SEVERITY_INFO: &str = "info";
const AMBIGUITY_TERMS: [&str; 10] = [
"easy",
"fast",
"flexible",
"intuitive",
"quickly",
"robust",
"scalable",
"seamless",
"simple",
"user-friendly",
];
const MANDATORY_TERMS: [&str; 2] = ["must", "shall"];
const HEDGE_TERMS: [&str; 3] = ["should", "may", "could"];
const ACCEPTANCE_SECTIONS: [&str; 1] = ["acceptance criteria"];
const SUCCESS_SECTIONS: [&str; 2] = ["success measures", "success metrics"];
#[derive(Debug, Clone)]
pub struct IntentFinding {
pub code: &'static str,
pub severity: &'static str, pub path: String, pub identifier: Option<String>,
pub detail: String, pub evidence: Vec<String>,
}
fn has_token(text: &str, token: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
let tok: Vec<char> = token.chars().collect();
let (n, m) = (chars.len(), tok.len());
if m == 0 || m > n {
return false;
}
for i in 0..=(n - m) {
if i > 0 && is_re_word(chars[i - 1]) {
continue; }
let matched = (0..m).all(|k| {
let tc = chars[i + k];
let wc = tok[k];
tc == wc || (tc.is_ascii_alphabetic() && tc.to_ascii_lowercase() == wc)
});
if matched && (i + m == n || !is_re_word(chars[i + m])) {
return true;
}
}
false
}
fn ambiguous_terms(text: &str) -> Vec<&'static str> {
AMBIGUITY_TERMS
.iter()
.copied()
.filter(|term| has_token(text, term))
.collect()
}
fn has_digit(text: &str) -> bool {
text.chars().any(is_re_digit)
}
fn has_mandatory(text: &str) -> bool {
MANDATORY_TERMS.iter().any(|t| has_token(text, t))
}
fn has_hedge(text: &str) -> bool {
HEDGE_TERMS.iter().any(|t| has_token(text, t))
}
fn section_filled(artifact: &Artifact, headings: &[&str]) -> bool {
headings
.iter()
.any(|h| !py_strip(artifact.section(h).unwrap_or("")).is_empty())
}
fn quoted_join(terms: &[&str]) -> String {
terms
.iter()
.map(|t| format!("'{t}'"))
.collect::<Vec<_>>()
.join(", ")
}
fn modified_findings(
change: &ArtifactChange,
base: &Artifact,
head: &Artifact,
) -> Vec<IntentFinding> {
let mut findings: Vec<IntentFinding> = Vec::new();
let empty = crate::diff::Diff::default();
let diff = change.diff.as_ref().unwrap_or(&empty);
for req_change in &diff.modified_requirements {
let evidence = vec![
format!("- {}", req_change.old_text),
format!("+ {}", req_change.new_text),
];
if has_digit(&req_change.old_text) && !has_digit(&req_change.new_text) {
findings.push(IntentFinding {
code: SPECIFICITY_REGRESSION,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!("Measurable requirement {} became vague.", req_change.id),
evidence: evidence.clone(),
});
}
let new_terms: Vec<&str> = ambiguous_terms(&req_change.new_text)
.into_iter()
.filter(|term| !has_token(&req_change.old_text, term))
.collect();
if !new_terms.is_empty() {
findings.push(IntentFinding {
code: AMBIGUITY_INTRODUCED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!(
"Ambiguous wording introduced in {}: {}.",
req_change.id,
quoted_join(&new_terms)
),
evidence: evidence.clone(),
});
}
if has_mandatory(&req_change.old_text)
&& !has_mandatory(&req_change.new_text)
&& has_hedge(&req_change.new_text)
{
findings.push(IntentFinding {
code: CONSTRAINT_WEAKENED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!(
"Mandatory requirement {} weakened to hedged wording.",
req_change.id
),
evidence,
});
}
}
for removed in &diff.removed_requirements {
if has_mandatory(&removed.text) {
findings.push(IntentFinding {
code: CONSTRAINT_REMOVED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!("Requirement {} with mandatory wording removed.", removed.id),
evidence: vec![format!("- {}", removed.text)],
});
}
}
if section_filled(base, &ACCEPTANCE_SECTIONS) && !section_filled(head, &ACCEPTANCE_SECTIONS) {
findings.push(IntentFinding {
code: ACCEPTANCE_CRITERIA_REMOVED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: "Acceptance criteria section removed.".to_string(),
evidence: Vec::new(),
});
}
if section_filled(base, &SUCCESS_SECTIONS) && !section_filled(head, &SUCCESS_SECTIONS) {
findings.push(IntentFinding {
code: SUCCESS_MEASURES_REMOVED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: "Success measures section removed.".to_string(),
evidence: Vec::new(),
});
}
findings
}
fn removed_findings(change: &ArtifactChange, base: &RepoState) -> Vec<IntentFinding> {
let mut findings = Vec::new();
if let Some(entry) = base.entry(&change.path) {
for requirement in &entry.artifact.product.requirements {
if has_mandatory(&requirement.text) {
findings.push(IntentFinding {
code: CONSTRAINT_REMOVED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!(
"Requirement {} with mandatory wording removed.",
requirement.id
),
evidence: vec![format!("- {}", requirement.text)],
});
}
}
}
findings
}
fn added_findings(change: &ArtifactChange, head: &RepoState) -> Vec<IntentFinding> {
let mut findings = Vec::new();
if let Some(entry) = head.entry(&change.path) {
for requirement in &entry.artifact.product.requirements {
let terms = ambiguous_terms(&requirement.text);
if !terms.is_empty() {
findings.push(IntentFinding {
code: AMBIGUITY_INTRODUCED,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!(
"Ambiguous wording introduced in {}: {}.",
requirement.id,
quoted_join(&terms)
),
evidence: vec![format!("+ {}", requirement.text)],
});
}
}
}
findings
}
fn reference_maps(state: &RepoState) -> (HashMap<String, Vec<String>>, Vec<String>) {
let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
let mut outgoing: Vec<String> = Vec::new();
for relationship in &state.relationships {
let source_rel = state.rel_of(&relationship.source_path);
if !outgoing.contains(&source_rel) {
outgoing.push(source_rel.clone());
}
if let Some(resolved) = &relationship.resolved_path {
let target_rel = state.rel_of(resolved);
let source_id = state
.entry(&source_rel)
.map(|e| e.info.id.clone())
.unwrap_or_else(|| source_rel.clone());
incoming.entry(target_rel).or_default().push(source_id);
}
}
(incoming, outgoing)
}
fn impact_finding(
change: &ArtifactChange,
incoming: &HashMap<String, Vec<String>>,
verb: &str,
) -> Option<IntentFinding> {
let mut sources: Vec<String> = incoming.get(&change.path).cloned().unwrap_or_default();
sources.sort();
sources.dedup();
if sources.is_empty() {
return None;
}
Some(IntentFinding {
code: RELATIONSHIP_IMPACT,
severity: SEVERITY_INFO,
path: change.path.clone(),
identifier: change.id.clone(),
detail: format!(
"{verb} artifact is referenced by {} artifact(s).",
sources.len()
),
evidence: sources,
})
}
pub fn analyze_intent(comparison: &RepositoryComparison) -> Vec<IntentFinding> {
let mut findings: Vec<IntentFinding> = Vec::new();
let (base_incoming, _) = reference_maps(&comparison.base);
let (head_incoming, head_outgoing) = reference_maps(&comparison.head);
for change in &comparison.changes {
if change.change == CHANGE_MODIFIED {
let base_product = comparison.base.entry(&change.path).map(|e| &e.artifact);
let head_product = comparison.head.entry(&change.path).map(|e| &e.artifact);
if let (Some(base_product), Some(head_product)) = (base_product, head_product) {
findings.extend(modified_findings(change, base_product, head_product));
}
if let Some(impact) = impact_finding(change, &head_incoming, "Modified") {
findings.push(impact);
}
} else if change.change == CHANGE_REMOVED {
findings.extend(removed_findings(change, &comparison.base));
if let Some(impact) = impact_finding(change, &base_incoming, "Removed") {
findings.push(impact);
}
} else if change.change == CHANGE_ADDED {
findings.extend(added_findings(change, &comparison.head));
if change.type_name != "unknown"
&& !head_outgoing.contains(&change.path)
&& !head_incoming.contains_key(&change.path)
{
findings.push(IntentFinding {
code: UNLINKED_SCOPE,
severity: SEVERITY_WARNING,
path: change.path.clone(),
identifier: change.id.clone(),
detail: "New artifact declares no relationships and nothing references it."
.to_string(),
evidence: Vec::new(),
});
}
}
}
findings.sort_by(|a, b| {
(a.severity != SEVERITY_WARNING, a.code, &a.path, &a.detail).cmp(&(
b.severity != SEVERITY_WARNING,
b.code,
&b.path,
&b.detail,
))
});
findings
}