1use 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 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
167pub 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
190pub(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
200pub(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 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
289fn cadence_finding(
292 directory: &str,
293 items: &[CorpusItem],
294 window_days: i64,
295) -> Option<ReviewIssue> {
296 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 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}