1use crate::classify::missing_sections;
6use crate::pycompat::py_round;
7use crate::relationships::{
8 summary_from_rows, validation_from_rows, validation_row, CorpusItem, RelationshipSummary,
9 ValidationRow, ISSUE_SELF_REFERENCE, ISSUE_TARGET_AMBIGUOUS, ISSUE_TARGET_NOT_FOUND,
10};
11use crate::validate::{apply_overrides, has_errors, load_overrides, py_title, validate, SeverityOverrides};
12
13pub const ATTENTION_INVALID: &str = "invalid-artifact";
15pub const ATTENTION_MISSING_RECOMMENDED: &str = "missing-recommended-sections";
16pub const ATTENTION_BROKEN_RELATIONSHIP: &str = "broken-relationship";
17
18const BY_TYPE_ORDER: [&str; 6] = [
20 "requirement",
21 "decision",
22 "roadmap",
23 "prompt",
24 "design",
25 "unknown",
26];
27
28#[derive(Debug, Clone)]
29pub struct AttentionItem {
30 pub path: String,
31 pub identifier: String,
32 pub severity: String,
33 pub code: String,
34 pub message: String,
35}
36
37#[derive(Debug)]
38pub struct PortfolioSummary {
39 pub directory: String,
40 pub recursive: bool,
41 pub by_type: Vec<(String, usize)>,
43 pub valid_artifacts: usize,
44 pub invalid_artifacts: usize,
45 pub recommended_slots: usize,
46 pub filled_slots: usize,
47 pub relationships: RelationshipSummary,
48 pub attention: Vec<AttentionItem>,
49 pub unknown_paths: Vec<String>,
50 pub relationships_ok: bool,
51}
52
53impl PortfolioSummary {
54 pub fn total_artifacts(&self) -> usize {
55 self.by_type.iter().map(|(_, c)| c).sum()
56 }
57
58 pub fn completeness(&self) -> f64 {
59 if self.recommended_slots == 0 {
60 return 1.0;
61 }
62 py_round(self.filled_slots as f64 / self.recommended_slots as f64, 4)
63 }
64
65 pub fn health_score(&self) -> i64 {
66 let total = self.total_artifacts();
67 let validity = if total != 0 {
68 self.valid_artifacts as f64 / total as f64
69 } else {
70 1.0
71 };
72 let completeness = self.completeness();
73 let checked = self.relationships.total;
74 let rel_integrity = if checked != 0 {
75 (checked - self.relationships.broken) as f64 / checked as f64
76 } else {
77 1.0
78 };
79 let raw = 0.5 * validity + 0.25 * completeness + 0.25 * rel_integrity;
80 py_round(100.0 * raw, 0) as i64
81 }
82}
83
84#[derive(Clone)]
86pub struct PortfolioRow {
87 path: String,
88 artifact_type: String,
89 identifier: String,
90 validation: ValidationRow,
91 validate_issues: Vec<crate::parse::Issue>,
92 recommended_slots: usize,
93 missing_recommended: Vec<String>,
94}
95
96pub fn portfolio_row(item: &CorpusItem) -> PortfolioRow {
97 let path = item.path.clone();
98 let artifact_type = item
99 .spec
100 .map(|s| s.name.clone())
101 .unwrap_or_else(|| "unknown".to_string());
102 let vrow = validation_row(&path, &item.artifact, item.spec);
103 match item.spec {
104 None => PortfolioRow {
105 path,
106 artifact_type,
107 identifier: vrow.canonical_id.clone(),
108 validation: vrow,
109 validate_issues: Vec::new(),
110 recommended_slots: 0,
111 missing_recommended: Vec::new(),
112 },
113 Some(spec) => {
114 let (_, missing_rec) = missing_sections(&item.artifact, spec);
115 let identifier =
116 crate::identity::artifact_identifier(&item.artifact, item.spec, &path);
117 PortfolioRow {
118 path,
119 artifact_type: artifact_type.clone(),
120 identifier,
121 validation: vrow,
122 validate_issues: validate(&item.artifact, None, Some(&artifact_type)),
123 recommended_slots: spec.recommended.len(),
124 missing_recommended: missing_rec,
125 }
126 }
127 }
128}
129
130fn rel_issue_phrase(code: &str) -> &'static str {
131 match code {
132 ISSUE_TARGET_NOT_FOUND => "references missing artifact",
133 ISSUE_TARGET_AMBIGUOUS => "has an ambiguous reference to",
134 ISSUE_SELF_REFERENCE => "references itself via",
135 _ => "has an unresolved reference",
136 }
137}
138
139pub fn portfolio_from_corpus(
141 directory: &str,
142 items: &[CorpusItem],
143 recursive: bool,
144) -> PortfolioSummary {
145 let rows: Vec<PortfolioRow> = items.iter().map(portfolio_row).collect();
146 portfolio_from_rows(directory, &rows, recursive)
147}
148
149pub fn portfolio_from_rows(
150 directory: &str,
151 rows: &[PortfolioRow],
152 recursive: bool,
153) -> PortfolioSummary {
154 let validation_rows: Vec<ValidationRow> = rows.iter().map(|r| r.validation.clone()).collect();
155 let overrides: SeverityOverrides = load_overrides(directory);
156
157 let mut by_type: Vec<(String, usize)> =
158 BY_TYPE_ORDER.iter().map(|t| (t.to_string(), 0)).collect();
159 let bump = |by_type: &mut Vec<(String, usize)>, t: &str| {
160 if let Some(entry) = by_type.iter_mut().find(|(k, _)| k == t) {
161 entry.1 += 1;
162 } else {
163 by_type.push((t.to_string(), 1));
164 }
165 };
166
167 let mut valid_count = 0usize;
168 let mut invalid_count = 0usize;
169 let mut recommended_slots = 0usize;
170 let mut filled_slots = 0usize;
171 let mut attention: Vec<AttentionItem> = Vec::new();
172 let mut unknown_paths: Vec<String> = Vec::new();
173 let mut path_to_identifier: std::collections::HashMap<String, String> =
174 std::collections::HashMap::new();
175
176 for row in rows {
177 bump(&mut by_type, &row.artifact_type);
178 if row.validation.spec_name.is_none() {
179 unknown_paths.push(row.path.clone());
180 continue;
181 }
182 path_to_identifier.insert(row.path.clone(), row.identifier.clone());
183
184 let issues = apply_overrides(row.validate_issues.clone(), &row.artifact_type, &overrides);
185 if has_errors(&issues) {
186 invalid_count += 1;
187 let error_codes: Vec<String> = issues
188 .iter()
189 .filter(|i| i.severity == "error")
190 .map(|i| i.code.clone())
191 .collect();
192 attention.push(AttentionItem {
193 path: row.path.clone(),
194 identifier: row.identifier.clone(),
195 severity: "error".to_string(),
196 code: ATTENTION_INVALID.to_string(),
197 message: format!("Validation errors: {}", error_codes.join(", ")),
198 });
199 } else {
200 valid_count += 1;
201 }
202
203 let slots = row.recommended_slots;
204 recommended_slots += slots;
205 let missing_rec = &row.missing_recommended;
206 filled_slots += slots - missing_rec.len();
207 if !missing_rec.is_empty() {
208 let names: Vec<String> = missing_rec.iter().map(|s| py_title(s)).collect();
209 attention.push(AttentionItem {
210 path: row.path.clone(),
211 identifier: row.identifier.clone(),
212 severity: "warning".to_string(),
213 code: ATTENTION_MISSING_RECOMMENDED.to_string(),
214 message: format!("Missing recommended sections: {}", names.join(", ")),
215 });
216 }
217 }
218
219 let rel_summary = summary_from_rows(&validation_rows);
220 let relationships_ok =
221 validation_from_rows(directory, &validation_rows, recursive).ok();
222
223 for issue in &rel_summary.issues {
224 let source = issue.source_path.clone().unwrap_or_default();
225 let label = py_title(&issue.relationship.clone().unwrap_or_default().replace('_', " "));
226 let phrase = rel_issue_phrase(&issue.code);
227 let identifier = path_to_identifier
228 .get(&source)
229 .cloned()
230 .unwrap_or_else(|| source.clone());
231 attention.push(AttentionItem {
232 path: source,
233 identifier,
234 severity: "warning".to_string(),
235 code: ATTENTION_BROKEN_RELATIONSHIP.to_string(),
236 message: format!(
237 "{label} {phrase}: {}",
238 issue.target.clone().unwrap_or_default()
239 ),
240 });
241 }
242
243 let sev_order = |s: &str| match s {
245 "error" => 0,
246 "warning" => 1,
247 _ => 2,
248 };
249 attention.sort_by(|a, b| {
250 sev_order(&a.severity)
251 .cmp(&sev_order(&b.severity))
252 .then(a.path.cmp(&b.path))
253 .then(a.code.cmp(&b.code))
254 });
255
256 PortfolioSummary {
257 directory: directory.to_string(),
258 recursive,
259 by_type,
260 valid_artifacts: valid_count,
261 invalid_artifacts: invalid_count,
262 recommended_slots,
263 filled_slots,
264 relationships: rel_summary,
265 attention,
266 unknown_paths,
267 relationships_ok,
268 }
269}