1use crate::audit_decision_surface::Decision;
13use crate::audit_walkthrough::{DirectionUnit, StandardWalkthroughGuide};
14
15#[must_use]
20pub fn decisions_outside_units(guide: &StandardWalkthroughGuide) -> Vec<&Decision> {
21 guide
22 .digest
23 .decisions
24 .decisions
25 .iter()
26 .filter(|decision| {
27 !guide
28 .direction
29 .order
30 .iter()
31 .any(|file| file == &decision.anchor_file)
32 })
33 .collect()
34}
35
36pub const MAX_CONTRACT_MEMBERS: usize = 6;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct WalkthroughAccounting {
45 pub changed: usize,
47 pub staged: usize,
49 pub cleared: usize,
51 pub excluded: usize,
54}
55
56impl WalkthroughAccounting {
57 #[must_use]
67 pub fn compute(guide: &StandardWalkthroughGuide, viewed: &[String]) -> Self {
68 let mut staged_visible = 0usize;
71 let mut collapsed = 0usize;
72 for file in &guide.direction.order {
73 if is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed) {
74 collapsed += 1;
75 } else {
76 staged_visible += 1;
77 }
78 }
79 let deprioritized_off_spine = guide
84 .digest
85 .focus
86 .deprioritized
87 .iter()
88 .filter(|u| !guide.direction.order.iter().any(|f| f == &u.file))
89 .count();
90 let cleared = collapsed + deprioritized_off_spine;
91 let source_units = guide.digest.focus.total_units();
95 let changed = guide.digest.triage.files;
96 let excluded = changed.saturating_sub(source_units);
97 WalkthroughAccounting {
98 changed,
99 staged: staged_visible,
100 cleared,
101 excluded,
102 }
103 }
104
105 #[must_use]
109 pub fn header_total(&self) -> usize {
110 (self.staged + self.cleared + self.excluded).max(self.changed)
111 }
112}
113
114#[must_use]
126pub fn clean_decision_fact(question: &str, anchor_file: &str, max_members: usize) -> String {
127 let stripped = strip_leading_path(question, anchor_file);
128 let capped = cap_member_list(&stripped, max_members);
129 drop_trailing_question(&capped)
130}
131
132fn strip_leading_path(question: &str, anchor_file: &str) -> String {
135 let prefix = format!("`{anchor_file}` ");
136 question
137 .strip_prefix(&prefix)
138 .map_or_else(|| question.to_string(), str::to_string)
139}
140
141fn cap_member_list(text: &str, max_members: usize) -> String {
145 let Some(open) = text.find('(') else {
146 return text.to_string();
147 };
148 let Some(rel_close) = text[open..].find(')') else {
149 return text.to_string();
150 };
151 let close = open + rel_close;
152 let inner = &text[open + 1..close];
153 let members: Vec<&str> = inner.split(", ").collect();
156 if members.len() <= max_members {
157 return text.to_string();
158 }
159 let shown = members[..max_members].join(", ");
160 let more = members.len() - max_members;
161 format!(
162 "{}({shown}, +{more} more){}",
163 &text[..open],
164 &text[close + 1..]
165 )
166}
167
168fn drop_trailing_question(text: &str) -> String {
174 let parts: Vec<&str> = text.split(". ").collect();
175 let mut end = parts.len();
176 while end > 0 && parts[end - 1].trim_end().ends_with('?') {
177 end -= 1;
178 }
179 if end == parts.len() || end == 0 {
182 return text.to_string();
183 }
184 let kept = parts[..end].join(". ");
185 if kept.ends_with(['.', '!', '?']) {
186 kept
187 } else {
188 format!("{kept}.")
189 }
190}
191
192#[must_use]
195pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
196 let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
197 let more = names.len().saturating_sub(shown.len());
198 (shown, more)
199}
200
201#[must_use]
205fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
206 viewed.iter().any(|v| v == file)
207}
208
209#[must_use]
213fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
214 guide
215 .digest
216 .focus
217 .deprioritized
218 .iter()
219 .any(|u| u.file == file)
220}
221
222#[must_use]
226fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
227 is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
228}
229
230#[must_use]
234pub fn visible_stage_units<'a>(
235 guide: &'a StandardWalkthroughGuide,
236 viewed: &[String],
237) -> Vec<&'a DirectionUnit> {
238 guide
239 .direction
240 .order
241 .iter()
242 .filter(|file| !collapses_into_cleared(guide, file, viewed))
243 .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
244 .collect()
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::audit_brief::{
251 DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
252 ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
253 };
254 use crate::audit_decision_surface::DecisionSurface;
255 use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
256 use crate::audit_routing::RoutingFacts;
257 use crate::audit_walkthrough::{
258 AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
259 };
260
261 fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
262 FocusUnit {
263 file: file.to_string(),
264 score: FocusScore::default(),
265 label,
266 reason: format!("reason for {file}"),
267 confidence: Vec::new(),
268 }
269 }
270
271 fn dir_unit(file: &str) -> DirectionUnit {
272 DirectionUnit {
273 file: file.to_string(),
274 concern_lens: "orientation".to_string(),
275 scoring_budget: 1,
276 out_of_diff: Vec::new(),
277 expert: Vec::new(),
278 test_adjacency: None,
279 }
280 }
281
282 fn guide_for(
286 review_here: &[&str],
287 deprioritized: &[&str],
288 changed_total: usize,
289 ) -> StandardWalkthroughGuide {
290 let order: Vec<String> = review_here
291 .iter()
292 .chain(deprioritized.iter())
293 .map(|s| (*s).to_string())
294 .collect();
295 let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
296 let digest = StandardReviewBriefOutput {
297 branching: None,
298 schema_version: ReviewBriefSchemaVersion::default(),
299 version: "test".to_string(),
300 command: "audit-brief".to_string(),
301 triage: DiffTriage {
302 files: changed_total,
303 hunks: None,
304 net_lines: None,
305 risk_class: RiskClass::Medium,
306 review_effort: ReviewEffort::Review,
307 },
308 graph_facts: GraphFacts {
309 exports_added: 0,
310 api_width_delta: 0,
311 boundaries_touched: Vec::new(),
312 },
313 partition: PartitionFacts::default(),
314 impact_closure: ImpactClosureFacts::default(),
315 focus: FocusMap {
316 review_here: review_here
317 .iter()
318 .map(|f| focus_unit(f, FocusLabel::ReviewHere))
319 .collect(),
320 deprioritized: deprioritized
321 .iter()
322 .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
323 .collect(),
324 },
325 deltas: ReviewDeltas::default(),
326 weakening: Vec::new(),
327 routing: RoutingFacts::default(),
328 decisions: DecisionSurface::default(),
329 };
330 StandardWalkthroughGuide {
331 schema_version: ReviewBriefSchemaVersion::default(),
332 version: "test".to_string(),
333 command: "review-walkthrough-guide".to_string(),
334 graph_snapshot_hash: "hash1".to_string(),
335 digest,
336 direction: ReviewDirection { order, units },
337 change_anchors: Vec::new(),
338 agent_schema: AgentSchema {
339 judgment_shape: "",
340 echo_field: "graph_snapshot_hash",
341 anchoring_rule: "",
342 action_vocabulary: &[],
343 concern_vocabulary: &[],
344 },
345 injection_note: INJECTION_NOTE,
346 }
347 }
348
349 #[test]
350 fn accounting_reconciles_staged_cleared_excluded() {
351 let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
354 let acc = WalkthroughAccounting::compute(&guide, &[]);
355 assert_eq!(acc.changed, 16);
356 assert_eq!(acc.staged, 2, "review-here source units stay in stages");
357 assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
358 assert_eq!(
359 acc.excluded, 13,
360 "non-source files are excluded, not dropped"
361 );
362 assert_eq!(acc.header_total(), 16);
364 assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
365 }
366
367 #[test]
368 fn viewed_file_moves_from_staged_to_cleared() {
369 let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
370 let viewed = vec!["src/a.ts".to_string()];
371 let acc = WalkthroughAccounting::compute(&guide, &viewed);
372 assert_eq!(acc.staged, 1, "the viewed file left the stage");
373 assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
374 assert_eq!(acc.excluded, 0);
375 assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
376 }
377
378 #[test]
379 fn deprioritized_and_viewed_appear_in_exactly_one_place() {
380 let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
381 let viewed = vec!["src/a.ts".to_string()];
382 let visible = visible_stage_units(&guide, &viewed);
385 let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
386 assert_eq!(files, vec!["src/b.ts"]);
387 assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
388 assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
389 assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
390 }
391
392 #[test]
393 fn strips_leading_path_caps_members_and_drops_question() {
394 let q = "`src/db/schema.ts` changes exports (a, b, c, d, e, f, g, h) imported by 32 files outside this PR. Does this change break or alter what those callers expect?";
395 let out = clean_decision_fact(q, "src/db/schema.ts", 3);
396 assert!(
398 !out.starts_with("`src/db/schema.ts`"),
399 "leading path must be stripped: {out}"
400 );
401 assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
403 assert!(
405 !out.contains('?'),
406 "trailing question must be dropped: {out}"
407 );
408 assert!(
409 out.ends_with("outside this PR."),
410 "the observation survives, ending cleanly: {out}"
411 );
412 assert!(!out.contains('`'), "no backticks remain: {out}");
414 }
415
416 #[test]
417 fn short_member_list_is_kept_and_question_dropped() {
418 let q = "`src/lib/r2.ts` changes exports (getR2, getR2Text) imported by 6 files outside this PR. Does this change break or alter what those callers expect?";
419 let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
420 assert_eq!(
421 out,
422 "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
423 );
424 }
425
426 #[test]
427 fn single_member_prose_parenthetical_is_kept_question_dropped() {
428 let q = "`src/lib/env.ts` changes export (env) imported by 22 files outside this PR. Does this change break or alter what those callers expect?";
429 let out = clean_decision_fact(q, "src/lib/env.ts", 6);
430 assert!(out.contains("(env)"), "single member kept: {out}");
431 assert!(!out.contains('?'), "trailing question dropped: {out}");
432 assert!(out.ends_with("outside this PR."), "observation kept: {out}");
433 }
434
435 #[test]
436 fn non_anchor_path_is_kept_but_question_dropped() {
437 let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
440 let out = clean_decision_fact(q, "src/ui/page.ts", 6);
441 assert_eq!(out, "`ui` now imports `db` for the first time.");
442 }
443
444 #[test]
445 fn public_api_surface_question_drops_to_one_sentence() {
446 let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
450 let out = clean_decision_fact(q, "src/lib/id.ts", 6);
451 assert_eq!(out, "This change adds 3 exports to the public API surface.");
452 }
453
454 #[test]
455 fn cap_names_first_k_then_more() {
456 let names = vec![
457 "a".to_string(),
458 "b".to_string(),
459 "c".to_string(),
460 "d".to_string(),
461 ];
462 let (shown, more) = cap_names(&names, 2);
463 assert_eq!(shown, vec!["a", "b"]);
464 assert_eq!(more, 2);
465 }
466}