1use crate::audit_walkthrough::{DirectionUnit, StandardWalkthroughGuide};
13
14pub const MAX_CONTRACT_MEMBERS: usize = 6;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct WalkthroughAccounting {
23 pub changed: usize,
25 pub staged: usize,
27 pub cleared: usize,
29 pub excluded: usize,
32}
33
34impl WalkthroughAccounting {
35 #[must_use]
45 pub fn compute(guide: &StandardWalkthroughGuide, viewed: &[String]) -> Self {
46 let mut staged_visible = 0usize;
49 let mut collapsed = 0usize;
50 for file in &guide.direction.order {
51 if is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed) {
52 collapsed += 1;
53 } else {
54 staged_visible += 1;
55 }
56 }
57 let deprioritized_off_spine = guide
62 .digest
63 .focus
64 .deprioritized
65 .iter()
66 .filter(|u| !guide.direction.order.iter().any(|f| f == &u.file))
67 .count();
68 let cleared = collapsed + deprioritized_off_spine;
69 let source_units = guide.digest.focus.total_units();
73 let changed = guide.digest.triage.files;
74 let excluded = changed.saturating_sub(source_units);
75 WalkthroughAccounting {
76 changed,
77 staged: staged_visible,
78 cleared,
79 excluded,
80 }
81 }
82
83 #[must_use]
87 pub fn header_total(&self) -> usize {
88 (self.staged + self.cleared + self.excluded).max(self.changed)
89 }
90}
91
92#[must_use]
104pub fn clean_decision_fact(question: &str, anchor_file: &str, max_members: usize) -> String {
105 let stripped = strip_leading_path(question, anchor_file);
106 let capped = cap_member_list(&stripped, max_members);
107 drop_trailing_question(&capped)
108}
109
110fn strip_leading_path(question: &str, anchor_file: &str) -> String {
113 let prefix = format!("`{anchor_file}` ");
114 question
115 .strip_prefix(&prefix)
116 .map_or_else(|| question.to_string(), str::to_string)
117}
118
119fn cap_member_list(text: &str, max_members: usize) -> String {
123 let Some(open) = text.find('(') else {
124 return text.to_string();
125 };
126 let Some(rel_close) = text[open..].find(')') else {
127 return text.to_string();
128 };
129 let close = open + rel_close;
130 let inner = &text[open + 1..close];
131 let members: Vec<&str> = inner.split(", ").collect();
134 if members.len() <= max_members {
135 return text.to_string();
136 }
137 let shown = members[..max_members].join(", ");
138 let more = members.len() - max_members;
139 format!(
140 "{}({shown}, +{more} more){}",
141 &text[..open],
142 &text[close + 1..]
143 )
144}
145
146fn drop_trailing_question(text: &str) -> String {
152 let parts: Vec<&str> = text.split(". ").collect();
153 let mut end = parts.len();
154 while end > 0 && parts[end - 1].trim_end().ends_with('?') {
155 end -= 1;
156 }
157 if end == parts.len() || end == 0 {
160 return text.to_string();
161 }
162 let kept = parts[..end].join(". ");
163 if kept.ends_with(['.', '!', '?']) {
164 kept
165 } else {
166 format!("{kept}.")
167 }
168}
169
170#[must_use]
173pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
174 let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
175 let more = names.len().saturating_sub(shown.len());
176 (shown, more)
177}
178
179#[must_use]
183fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
184 viewed.iter().any(|v| v == file)
185}
186
187#[must_use]
191fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
192 guide
193 .digest
194 .focus
195 .deprioritized
196 .iter()
197 .any(|u| u.file == file)
198}
199
200#[must_use]
204fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
205 is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
206}
207
208#[must_use]
212pub fn visible_stage_units<'a>(
213 guide: &'a StandardWalkthroughGuide,
214 viewed: &[String],
215) -> Vec<&'a DirectionUnit> {
216 guide
217 .direction
218 .order
219 .iter()
220 .filter(|file| !collapses_into_cleared(guide, file, viewed))
221 .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
222 .collect()
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::audit_brief::{
229 DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
230 ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
231 };
232 use crate::audit_decision_surface::DecisionSurface;
233 use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
234 use crate::audit_routing::RoutingFacts;
235 use crate::audit_walkthrough::{
236 AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
237 };
238
239 fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
240 FocusUnit {
241 file: file.to_string(),
242 score: FocusScore::default(),
243 label,
244 reason: format!("reason for {file}"),
245 confidence: Vec::new(),
246 }
247 }
248
249 fn dir_unit(file: &str) -> DirectionUnit {
250 DirectionUnit {
251 file: file.to_string(),
252 concern_lens: "orientation".to_string(),
253 scoring_budget: 1,
254 out_of_diff: Vec::new(),
255 expert: Vec::new(),
256 }
257 }
258
259 fn guide_for(
263 review_here: &[&str],
264 deprioritized: &[&str],
265 changed_total: usize,
266 ) -> StandardWalkthroughGuide {
267 let order: Vec<String> = review_here
268 .iter()
269 .chain(deprioritized.iter())
270 .map(|s| (*s).to_string())
271 .collect();
272 let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
273 let digest = StandardReviewBriefOutput {
274 schema_version: ReviewBriefSchemaVersion::default(),
275 version: "test".to_string(),
276 command: "audit-brief".to_string(),
277 triage: DiffTriage {
278 files: changed_total,
279 hunks: None,
280 net_lines: None,
281 risk_class: RiskClass::Medium,
282 review_effort: ReviewEffort::Review,
283 },
284 graph_facts: GraphFacts {
285 exports_added: 0,
286 api_width_delta: 0,
287 reachable_from: Vec::new(),
288 boundaries_touched: Vec::new(),
289 },
290 partition: PartitionFacts::default(),
291 impact_closure: ImpactClosureFacts::default(),
292 focus: FocusMap {
293 review_here: review_here
294 .iter()
295 .map(|f| focus_unit(f, FocusLabel::ReviewHere))
296 .collect(),
297 deprioritized: deprioritized
298 .iter()
299 .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
300 .collect(),
301 },
302 deltas: ReviewDeltas::default(),
303 weakening: Vec::new(),
304 routing: RoutingFacts::default(),
305 decisions: DecisionSurface::default(),
306 };
307 StandardWalkthroughGuide {
308 schema_version: ReviewBriefSchemaVersion::default(),
309 version: "test".to_string(),
310 command: "review-walkthrough-guide".to_string(),
311 graph_snapshot_hash: "hash1".to_string(),
312 digest,
313 direction: ReviewDirection { order, units },
314 change_anchors: Vec::new(),
315 agent_schema: AgentSchema {
316 judgment_shape: "",
317 echo_field: "graph_snapshot_hash",
318 anchoring_rule: "",
319 },
320 injection_note: INJECTION_NOTE,
321 }
322 }
323
324 #[test]
325 fn accounting_reconciles_staged_cleared_excluded() {
326 let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
329 let acc = WalkthroughAccounting::compute(&guide, &[]);
330 assert_eq!(acc.changed, 16);
331 assert_eq!(acc.staged, 2, "review-here source units stay in stages");
332 assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
333 assert_eq!(
334 acc.excluded, 13,
335 "non-source files are excluded, not dropped"
336 );
337 assert_eq!(acc.header_total(), 16);
339 assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
340 }
341
342 #[test]
343 fn viewed_file_moves_from_staged_to_cleared() {
344 let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
345 let viewed = vec!["src/a.ts".to_string()];
346 let acc = WalkthroughAccounting::compute(&guide, &viewed);
347 assert_eq!(acc.staged, 1, "the viewed file left the stage");
348 assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
349 assert_eq!(acc.excluded, 0);
350 assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
351 }
352
353 #[test]
354 fn deprioritized_and_viewed_appear_in_exactly_one_place() {
355 let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
356 let viewed = vec!["src/a.ts".to_string()];
357 let visible = visible_stage_units(&guide, &viewed);
360 let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
361 assert_eq!(files, vec!["src/b.ts"]);
362 assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
363 assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
364 assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
365 }
366
367 #[test]
368 fn strips_leading_path_caps_members_and_drops_question() {
369 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?";
370 let out = clean_decision_fact(q, "src/db/schema.ts", 3);
371 assert!(
373 !out.starts_with("`src/db/schema.ts`"),
374 "leading path must be stripped: {out}"
375 );
376 assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
378 assert!(
380 !out.contains('?'),
381 "trailing question must be dropped: {out}"
382 );
383 assert!(
384 out.ends_with("outside this PR."),
385 "the observation survives, ending cleanly: {out}"
386 );
387 assert!(!out.contains('`'), "no backticks remain: {out}");
389 }
390
391 #[test]
392 fn short_member_list_is_kept_and_question_dropped() {
393 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?";
394 let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
395 assert_eq!(
396 out,
397 "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
398 );
399 }
400
401 #[test]
402 fn single_member_prose_parenthetical_is_kept_question_dropped() {
403 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?";
404 let out = clean_decision_fact(q, "src/lib/env.ts", 6);
405 assert!(out.contains("(env)"), "single member kept: {out}");
406 assert!(!out.contains('?'), "trailing question dropped: {out}");
407 assert!(out.ends_with("outside this PR."), "observation kept: {out}");
408 }
409
410 #[test]
411 fn non_anchor_path_is_kept_but_question_dropped() {
412 let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
415 let out = clean_decision_fact(q, "src/ui/page.ts", 6);
416 assert_eq!(out, "`ui` now imports `db` for the first time.");
417 }
418
419 #[test]
420 fn public_api_surface_question_drops_to_one_sentence() {
421 let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
425 let out = clean_decision_fact(q, "src/lib/id.ts", 6);
426 assert_eq!(out, "This change adds 3 exports to the public API surface.");
427 }
428
429 #[test]
430 fn cap_names_first_k_then_more() {
431 let names = vec![
432 "a".to_string(),
433 "b".to_string(),
434 "c".to_string(),
435 "d".to_string(),
436 ];
437 let (shown, more) = cap_names(&names, 2);
438 assert_eq!(shown, vec!["a", "b"]);
439 assert_eq!(more, 2);
440 }
441}