Skip to main content

rac_engine/
review.rs

1//! Repository review (`decided.services.review`) — the prioritized, actionable
2//! report `decided review` renders. Composes the portfolio summary, git-native
3//! drift advisories, and an optional write-cadence nudge.
4
5use std::path::{Path, PathBuf};
6
7use crate::gitinfo;
8use crate::portfolio::{
9    portfolio_from_corpus, AttentionItem, PortfolioSummary, ATTENTION_BROKEN_RELATIONSHIP,
10    ATTENTION_INVALID, ATTENTION_MISSING_RECOMMENDED,
11};
12use crate::relationships::{corpus_items, relationships_from_corpus, CorpusItem};
13
14pub const PRIORITY_INVALID_ARTIFACT: i64 = 1;
15pub const PRIORITY_BROKEN_RELATIONSHIP: i64 = 2;
16pub const PRIORITY_UNKNOWN_ARTIFACT: i64 = 3;
17pub const PRIORITY_MISSING_RECOMMENDED: i64 = 4;
18pub const PRIORITY_STALE_CORPUS: i64 = 5;
19pub const PRIORITY_SUSPECT_DRIFT: i64 = 6;
20
21pub const REVIEW_UNKNOWN_ARTIFACT: &str = "unknown-artifact";
22pub const REVIEW_STALE_CORPUS: &str = "stale-corpus";
23pub const REVIEW_SUSPECT_ARTIFACT: &str = "suspect-artifact";
24
25const GENERIC_IMPACT: &str = "This finding affects repository quality.";
26
27fn impact_for(code: &str) -> &'static str {
28    match code {
29        ATTENTION_INVALID => {
30            "The artifact fails its schema, so tooling and validation cannot trust it."
31        }
32        ATTENTION_BROKEN_RELATIONSHIP => {
33            "A declared reference does not resolve, leaving traceability incomplete."
34        }
35        ATTENTION_MISSING_RECOMMENDED => {
36            "Recommended sections are empty, weakening the artifact's completeness."
37        }
38        REVIEW_UNKNOWN_ARTIFACT => {
39            "No schema matched, so required structure cannot be checked."
40        }
41        REVIEW_STALE_CORPUS => {
42            "The write habit has stalled; product knowledge stops reflecting the work."
43        }
44        REVIEW_SUSPECT_ARTIFACT => {
45            "A referenced artifact changed after this one did, so the reference may be stale."
46        }
47        _ => GENERIC_IMPACT,
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct ReviewIssue {
53    pub priority: i64,
54    pub severity: String,
55    pub path: String,
56    pub identifier: String,
57    pub code: String,
58    pub message: String,
59    pub action: String,
60    pub impact: String,
61}
62
63pub struct ReviewReport {
64    pub directory: String,
65    pub recursive: bool,
66    pub portfolio: PortfolioSummary,
67    pub issues: Vec<ReviewIssue>,
68}
69
70impl ReviewReport {
71    pub fn ok(&self) -> bool {
72        !self
73            .issues
74            .iter()
75            .any(|i| i.priority <= PRIORITY_BROKEN_RELATIONSHIP)
76    }
77
78    /// Deduplicated suggested actions in issue (priority) order.
79    pub fn actions(&self) -> Vec<String> {
80        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
81        let mut ordered = Vec::new();
82        for issue in &self.issues {
83            if seen.insert(issue.action.clone()) {
84                ordered.push(issue.action.clone());
85            }
86        }
87        ordered
88    }
89}
90
91fn attention_priority(code: &str) -> i64 {
92    match code {
93        ATTENTION_INVALID => PRIORITY_INVALID_ARTIFACT,
94        ATTENTION_BROKEN_RELATIONSHIP => PRIORITY_BROKEN_RELATIONSHIP,
95        ATTENTION_MISSING_RECOMMENDED => PRIORITY_MISSING_RECOMMENDED,
96        _ => PRIORITY_MISSING_RECOMMENDED,
97    }
98}
99
100fn sort_issues(issues: &mut [ReviewIssue]) {
101    issues.sort_by(|a, b| {
102        a.priority
103            .cmp(&b.priority)
104            .then(a.path.cmp(&b.path))
105            .then(a.code.cmp(&b.code))
106    });
107}
108
109pub fn review_from_portfolio(
110    directory: &str,
111    portfolio: PortfolioSummary,
112    recursive: bool,
113) -> ReviewReport {
114    let mut issues: Vec<ReviewIssue> = Vec::new();
115
116    for item in &portfolio.attention {
117        let AttentionItem {
118            path,
119            identifier,
120            severity,
121            code,
122            message,
123        } = item;
124        let priority = attention_priority(code);
125        let action = if code == ATTENTION_INVALID {
126            format!("Run: decided validate {path}")
127        } else if code == ATTENTION_BROKEN_RELATIONSHIP {
128            format!("Run: decided relationships {directory} --validate")
129        } else {
130            format!("Run: decided improve {path} --template")
131        };
132        issues.push(ReviewIssue {
133            priority,
134            severity: severity.clone(),
135            path: path.clone(),
136            identifier: identifier.clone(),
137            code: code.clone(),
138            message: message.clone(),
139            action,
140            impact: impact_for(code).to_string(),
141        });
142    }
143
144    for path in &portfolio.unknown_paths {
145        issues.push(ReviewIssue {
146            priority: PRIORITY_UNKNOWN_ARTIFACT,
147            severity: "info".to_string(),
148            path: path.clone(),
149            identifier: crate::identity::path_stem(path),
150            code: REVIEW_UNKNOWN_ARTIFACT.to_string(),
151            message: "No artifact schema matched this document.".to_string(),
152            action: format!("Run: decided inspect {path} (see decided schema --list)"),
153            impact: impact_for(REVIEW_UNKNOWN_ARTIFACT).to_string(),
154        });
155    }
156
157    sort_issues(&mut issues);
158
159    ReviewReport {
160        directory: directory.to_string(),
161        recursive,
162        portfolio,
163        issues,
164    }
165}
166
167/// `build_review(directory, recursive, stale_after_days)`.
168pub fn build_review(
169    directory: &str,
170    recursive: bool,
171    stale_after_days: Option<i64>,
172) -> ReviewReport {
173    let items = corpus_items(directory, recursive);
174    let portfolio = portfolio_from_corpus(directory, &items, recursive);
175    let mut report = review_from_portfolio(directory, portfolio, recursive);
176
177    let mut advisories: Vec<ReviewIssue> = drift_findings(directory, &items);
178    if let Some(window) = stale_after_days {
179        if let Some(finding) = cadence_finding(directory, &items, window) {
180            advisories.push(finding);
181        }
182    }
183    if !advisories.is_empty() {
184        report.issues.extend(advisories);
185        sort_issues(&mut report.issues);
186    }
187    report
188}
189
190// --- git-native drift --------------------------------------------------------
191
192pub(crate) struct DriftRecord {
193    pub(crate) source_path: String,
194    pub(crate) target_path: String,
195    pub(crate) target_ref: String,
196    pub(crate) source_committed: String,
197    pub(crate) target_committed: String,
198}
199
200/// `suspect_drift(directory, entries)` — resolved edges whose target was
201/// committed strictly after the referrer. Deduped per `(source, target)`,
202/// sorted by `(source_path, target_path)`.
203pub(crate) fn suspect_drift(directory: &str, items: &[CorpusItem]) -> Vec<DriftRecord> {
204    let resolved: Vec<crate::relationships::Relationship> = relationships_from_corpus(items)
205        .into_iter()
206        .filter(|r| r.resolved_path.is_some())
207        .collect();
208    if resolved.is_empty() {
209        return Vec::new();
210    }
211
212    let mut involved: Vec<PathBuf> = Vec::new();
213    let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
214    for rel in &resolved {
215        for p in [&rel.source_path, rel.resolved_path.as_ref().unwrap()] {
216            if seen_paths.insert(p.clone()) {
217                involved.push(PathBuf::from(p));
218            }
219        }
220    }
221    let committed_pairs = gitinfo::last_committed_for_paths(Path::new(directory), &involved);
222    let committed: std::collections::HashMap<String, Option<String>> = committed_pairs
223        .into_iter()
224        .map(|(p, v)| (p.to_string_lossy().into_owned(), v))
225        .collect();
226
227    let mut records: Vec<DriftRecord> = Vec::new();
228    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
229    for rel in &resolved {
230        let target_path = rel.resolved_path.clone().unwrap();
231        let source_when = committed.get(&rel.source_path).and_then(|v| v.clone());
232        let target_when = committed.get(&target_path).and_then(|v| v.clone());
233        let (source_when, target_when) = match (source_when, target_when) {
234            (Some(s), Some(t)) => (s, t),
235            _ => continue,
236        };
237        let source_epoch = gitinfo::parse_iso8601_epoch(&source_when);
238        let target_epoch = gitinfo::parse_iso8601_epoch(&target_when);
239        // target newer than source (strictly) => suspect.
240        match (source_epoch, target_epoch) {
241            (Some(se), Some(te)) if te > se => {}
242            _ => continue,
243        }
244        let key = (rel.source_path.clone(), target_path.clone());
245        if !seen.insert(key) {
246            continue;
247        }
248        records.push(DriftRecord {
249            source_path: rel.source_path.clone(),
250            target_path: target_path.clone(),
251            target_ref: rel.target.clone(),
252            source_committed: source_when,
253            target_committed: target_when,
254        });
255    }
256    records.sort_by(|a, b| {
257        a.source_path
258            .cmp(&b.source_path)
259            .then(a.target_path.cmp(&b.target_path))
260    });
261    records
262}
263
264fn drift_findings(directory: &str, items: &[CorpusItem]) -> Vec<ReviewIssue> {
265    suspect_drift(directory, items)
266        .into_iter()
267        .map(|record| ReviewIssue {
268            priority: PRIORITY_SUSPECT_DRIFT,
269            severity: "warning".to_string(),
270            path: record.source_path.clone(),
271            identifier: crate::identity::path_stem(&record.source_path),
272            code: REVIEW_SUSPECT_ARTIFACT.to_string(),
273            message: drift_problem(&record),
274            action: format!("Run: decided doctor {directory}"),
275            impact: impact_for(REVIEW_SUSPECT_ARTIFACT).to_string(),
276        })
277        .collect()
278}
279
280pub(crate) fn drift_problem(record: &DriftRecord) -> String {
281    format!(
282        "references {} which changed more recently (target last committed {}, this artifact {}) — review recommended",
283        record.target_ref,
284        gitinfo::isoformat_roundtrip(&record.target_committed),
285        gitinfo::isoformat_roundtrip(&record.source_committed),
286    )
287}
288
289// --- write-cadence nudge -----------------------------------------------------
290
291fn cadence_finding(
292    directory: &str,
293    items: &[CorpusItem],
294    window_days: i64,
295) -> Option<ReviewIssue> {
296    // most_recent = newest last-committed across recognised (non-unknown) artifacts.
297    let recognised: Vec<PathBuf> = items
298        .iter()
299        .filter(|i| i.spec.is_some())
300        .map(|i| PathBuf::from(&i.path))
301        .collect();
302    if recognised.is_empty() {
303        return None;
304    }
305    let committed = gitinfo::last_committed_for_paths(Path::new(directory), &recognised);
306    let mut most_recent_epoch: Option<i64> = None;
307    let mut most_recent_stamp: Option<String> = None;
308    for (_, stamp) in committed {
309        if let Some(stamp) = stamp {
310            if let Some(epoch) = gitinfo::parse_iso8601_epoch(&stamp) {
311                if most_recent_epoch.map(|e| epoch > e).unwrap_or(true) {
312                    most_recent_epoch = Some(epoch);
313                    most_recent_stamp = Some(stamp);
314                }
315            }
316        }
317    }
318    let (most_recent_epoch, _) = (most_recent_epoch?, most_recent_stamp);
319
320    let now = std::time::SystemTime::now()
321        .duration_since(std::time::UNIX_EPOCH)
322        .map(|d| d.as_secs() as i64)
323        .unwrap_or(0);
324    // age.days = floor((now - most_recent)/86400); age <= window => suppress.
325    let delta = now - most_recent_epoch;
326    let age_days = delta.div_euclid(86_400);
327    if age_days <= window_days {
328        return None;
329    }
330    Some(ReviewIssue {
331        priority: PRIORITY_STALE_CORPUS,
332        severity: "info".to_string(),
333        path: directory.to_string(),
334        identifier: "corpus".to_string(),
335        code: REVIEW_STALE_CORPUS.to_string(),
336        message: format!(
337            "No product knowledge recorded in the last {window_days} days (newest artifact is {age_days} days old)."
338        ),
339        action: "Run: decided new decision decisions/decisions/<name>.md".to_string(),
340        impact: impact_for(REVIEW_STALE_CORPUS).to_string(),
341    })
342}