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
141#[must_use]
160pub fn cap_member_list(text: &str, max_members: usize) -> String {
161 if max_members == 0 {
162 return text.to_string();
163 }
164 let mut from = 0usize;
165 while let Some(rel_open) = text[from..].find('(') {
166 let open = from + rel_open;
167 let Some(close) = matching_close_paren(text, open) else {
170 from = open + 1;
171 continue;
172 };
173 let inner = &text[open + 1..close];
174 let members = split_top_level_members(inner);
175 if members.len() < 2 {
178 from = open + 1;
179 continue;
180 }
181 if members.len() <= max_members {
182 return text.to_string();
183 }
184 let shown = members[..max_members].join(", ");
185 let more = members.len() - max_members;
186 return format!(
187 "{}({shown}, +{more} more){}",
188 &text[..open],
189 &text[close + 1..]
190 );
191 }
192 text.to_string()
193}
194
195fn matching_close_paren(text: &str, open: usize) -> Option<usize> {
198 let mut depth = 0usize;
199 for (offset, ch) in text[open..].char_indices() {
200 match ch {
201 '(' => depth += 1,
202 ')' => {
203 depth -= 1;
204 if depth == 0 {
205 return Some(open + offset);
206 }
207 }
208 _ => {}
209 }
210 }
211 None
212}
213
214fn split_top_level_members(inner: &str) -> Vec<&str> {
216 let mut members = Vec::new();
217 let mut depth = 0usize;
218 let mut start = 0usize;
219 for (offset, ch) in inner.char_indices() {
220 match ch {
221 '(' => depth += 1,
222 ')' => depth = depth.saturating_sub(1),
223 ',' if depth == 0 && inner[offset..].starts_with(", ") => {
224 members.push(&inner[start..offset]);
225 start = offset + 2;
226 }
227 _ => {}
228 }
229 }
230 members.push(&inner[start..]);
231 members
232}
233
234fn drop_trailing_question(text: &str) -> String {
240 let parts: Vec<&str> = text.split(". ").collect();
241 let mut end = parts.len();
242 while end > 0 && parts[end - 1].trim_end().ends_with('?') {
243 end -= 1;
244 }
245 if end == parts.len() || end == 0 {
248 return text.to_string();
249 }
250 let kept = parts[..end].join(". ");
251 if kept.ends_with(['.', '!', '?']) {
252 kept
253 } else {
254 format!("{kept}.")
255 }
256}
257
258#[must_use]
261pub fn cap_names(names: &[String], max: usize) -> (Vec<&str>, usize) {
262 let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect();
263 let more = names.len().saturating_sub(shown.len());
264 (shown, more)
265}
266
267#[must_use]
271fn is_collapsed_into_cleared(file: &str, viewed: &[String]) -> bool {
272 viewed.iter().any(|v| v == file)
273}
274
275#[must_use]
279fn is_deprioritized(guide: &StandardWalkthroughGuide, file: &str) -> bool {
280 guide
281 .digest
282 .focus
283 .deprioritized
284 .iter()
285 .any(|u| u.file == file)
286}
287
288#[must_use]
292fn collapses_into_cleared(guide: &StandardWalkthroughGuide, file: &str, viewed: &[String]) -> bool {
293 is_deprioritized(guide, file) || is_collapsed_into_cleared(file, viewed)
294}
295
296#[must_use]
300pub fn visible_stage_units<'a>(
301 guide: &'a StandardWalkthroughGuide,
302 viewed: &[String],
303) -> Vec<&'a DirectionUnit> {
304 guide
305 .direction
306 .order
307 .iter()
308 .filter(|file| !collapses_into_cleared(guide, file, viewed))
309 .filter_map(|file| guide.direction.units.iter().find(|u| &u.file == file))
310 .collect()
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::audit_brief::{
317 DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion,
318 ReviewDeltas, ReviewEffort, RiskClass, StandardReviewBriefOutput,
319 };
320 use crate::audit_decision_surface::DecisionSurface;
321 use crate::audit_focus::{FocusLabel, FocusMap, FocusScore, FocusUnit};
322 use crate::audit_routing::RoutingFacts;
323 use crate::audit_walkthrough::{
324 AgentSchema, DirectionUnit, INJECTION_NOTE, ReviewDirection, StandardWalkthroughGuide,
325 };
326
327 fn focus_unit(file: &str, label: FocusLabel) -> FocusUnit {
328 FocusUnit {
329 file: file.to_string(),
330 score: FocusScore::default(),
331 label,
332 reason: format!("reason for {file}"),
333 confidence: Vec::new(),
334 }
335 }
336
337 fn dir_unit(file: &str) -> DirectionUnit {
338 DirectionUnit {
339 file: file.to_string(),
340 concern_lens: "orientation".to_string(),
341 scoring_budget: 1,
342 out_of_diff: Vec::new(),
343 expert: Vec::new(),
344 test_adjacency: None,
345 }
346 }
347
348 fn guide_for(
352 review_here: &[&str],
353 deprioritized: &[&str],
354 changed_total: usize,
355 ) -> StandardWalkthroughGuide {
356 let order: Vec<String> = review_here
357 .iter()
358 .chain(deprioritized.iter())
359 .map(|s| (*s).to_string())
360 .collect();
361 let units: Vec<DirectionUnit> = order.iter().map(|f| dir_unit(f)).collect();
362 let digest = StandardReviewBriefOutput {
363 branching: None,
364 schema_version: ReviewBriefSchemaVersion::default(),
365 version: "test".to_string(),
366 command: "audit-brief".to_string(),
367 triage: DiffTriage {
368 files: changed_total,
369 hunks: None,
370 net_lines: None,
371 risk_class: RiskClass::Medium,
372 review_effort: ReviewEffort::Review,
373 },
374 graph_facts: GraphFacts {
375 exports_added: 0,
376 api_width_delta: 0,
377 boundaries_touched: Vec::new(),
378 },
379 partition: PartitionFacts::default(),
380 impact_closure: ImpactClosureFacts::default(),
381 focus: FocusMap {
382 review_here: review_here
383 .iter()
384 .map(|f| focus_unit(f, FocusLabel::ReviewHere))
385 .collect(),
386 deprioritized: deprioritized
387 .iter()
388 .map(|f| focus_unit(f, FocusLabel::NotPrioritized))
389 .collect(),
390 },
391 deltas: ReviewDeltas::default(),
392 weakening: Vec::new(),
393 routing: RoutingFacts::default(),
394 ownership: None,
395 decisions: DecisionSurface::default(),
396 };
397 StandardWalkthroughGuide {
398 schema_version: ReviewBriefSchemaVersion::default(),
399 version: "test".to_string(),
400 command: "review-walkthrough-guide".to_string(),
401 graph_snapshot_hash: "hash1".to_string(),
402 digest,
403 direction: ReviewDirection { order, units },
404 change_anchors: Vec::new(),
405 agent_schema: AgentSchema {
406 judgment_shape: "",
407 echo_field: "graph_snapshot_hash",
408 anchoring_rule: "",
409 action_vocabulary: &[],
410 concern_vocabulary: &[],
411 },
412 injection_note: INJECTION_NOTE,
413 }
414 }
415
416 #[test]
417 fn accounting_reconciles_staged_cleared_excluded() {
418 let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 16);
421 let acc = WalkthroughAccounting::compute(&guide, &[]);
422 assert_eq!(acc.changed, 16);
423 assert_eq!(acc.staged, 2, "review-here source units stay in stages");
424 assert_eq!(acc.cleared, 1, "de-prioritized collapses into cleared");
425 assert_eq!(
426 acc.excluded, 13,
427 "non-source files are excluded, not dropped"
428 );
429 assert_eq!(acc.header_total(), 16);
431 assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
432 }
433
434 #[test]
435 fn viewed_file_moves_from_staged_to_cleared() {
436 let guide = guide_for(&["src/a.ts", "src/b.ts"], &[], 2);
437 let viewed = vec!["src/a.ts".to_string()];
438 let acc = WalkthroughAccounting::compute(&guide, &viewed);
439 assert_eq!(acc.staged, 1, "the viewed file left the stage");
440 assert_eq!(acc.cleared, 1, "the viewed file is counted in cleared");
441 assert_eq!(acc.excluded, 0);
442 assert_eq!(acc.staged + acc.cleared + acc.excluded, acc.changed);
443 }
444
445 #[test]
446 fn deprioritized_and_viewed_appear_in_exactly_one_place() {
447 let guide = guide_for(&["src/a.ts", "src/b.ts"], &["src/c.ts"], 3);
448 let viewed = vec!["src/a.ts".to_string()];
449 let visible = visible_stage_units(&guide, &viewed);
452 let files: Vec<&str> = visible.iter().map(|u| u.file.as_str()).collect();
453 assert_eq!(files, vec!["src/b.ts"]);
454 assert!(collapses_into_cleared(&guide, "src/a.ts", &viewed));
455 assert!(collapses_into_cleared(&guide, "src/c.ts", &viewed));
456 assert!(!collapses_into_cleared(&guide, "src/b.ts", &viewed));
457 }
458
459 #[test]
460 fn strips_leading_path_caps_members_and_drops_question() {
461 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?";
462 let out = clean_decision_fact(q, "src/db/schema.ts", 3);
463 assert!(
465 !out.starts_with("`src/db/schema.ts`"),
466 "leading path must be stripped: {out}"
467 );
468 assert!(out.contains("(a, b, c, +5 more)"), "got: {out}");
470 assert!(
472 !out.contains('?'),
473 "trailing question must be dropped: {out}"
474 );
475 assert!(
476 out.ends_with("outside this PR."),
477 "the observation survives, ending cleanly: {out}"
478 );
479 assert!(!out.contains('`'), "no backticks remain: {out}");
481 }
482
483 #[test]
484 fn short_member_list_is_kept_and_question_dropped() {
485 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?";
486 let out = clean_decision_fact(q, "src/lib/r2.ts", 6);
487 assert_eq!(
488 out,
489 "changes exports (getR2, getR2Text) imported by 6 files outside this PR."
490 );
491 }
492
493 #[test]
494 fn single_member_prose_parenthetical_is_kept_question_dropped() {
495 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?";
496 let out = clean_decision_fact(q, "src/lib/env.ts", 6);
497 assert!(out.contains("(env)"), "single member kept: {out}");
498 assert!(!out.contains('?'), "trailing question dropped: {out}");
499 assert!(out.ends_with("outside this PR."), "observation kept: {out}");
500 }
501
502 #[test]
503 fn a_route_group_segment_is_not_mistaken_for_the_member_list() {
504 let q = "`app/(marketing)/lib/api.ts` changes exports (a, b, c, d, e) imported by 9 files outside this PR.";
509 let out = cap_member_list(q, 3);
510 assert!(
511 out.starts_with("`app/(marketing)/lib/api.ts` "),
512 "the route group survives verbatim: {out}"
513 );
514 assert!(
515 out.contains("(a, b, c, +2 more)"),
516 "the export list is the parenthetical that collapses: {out}"
517 );
518 }
519
520 #[test]
521 fn a_route_group_anchor_caps_the_tour_fact_too() {
522 let q = "`src/routes/(app)/+page.server.ts` changes exports (load, actions, prerender, ssr) imported by 4 files outside this PR. Does this change break or alter what those callers expect?";
523 let out = clean_decision_fact(q, "src/routes/(app)/+page.server.ts", 2);
524 assert_eq!(
525 out,
526 "changes exports (load, actions, +2 more) imported by 4 files outside this PR."
527 );
528 }
529
530 #[test]
531 fn a_prose_parenthetical_before_the_members_does_not_end_the_scan() {
532 let out = cap_member_list(
535 "changes exports (env) and (a, b, c, d) imported by 2 files outside this PR.",
536 2,
537 );
538 assert_eq!(
539 out,
540 "changes exports (env) and (a, b, +2 more) imported by 2 files outside this PR."
541 );
542 }
543
544 #[test]
545 fn a_dev_marker_inside_the_list_does_not_split_it() {
546 let q = "`package.json` moves 5 dependencies across a major version (`a` 1 -> 2, \
550 `b` (dev) 1 -> 2, `c` 1 -> 2, `d` (dev) 1 -> 2, `e` 1 -> 2), imported by \
551 9 in-repo modules. Which changes reach those importers?";
552 assert_eq!(
553 cap_member_list(q, 3),
554 "`package.json` moves 5 dependencies across a major version (`a` 1 -> 2, \
555 `b` (dev) 1 -> 2, `c` 1 -> 2, +2 more), imported by 9 in-repo modules. \
556 Which changes reach those importers?"
557 );
558 }
559
560 #[test]
561 fn an_unclosed_parenthetical_leaves_the_text_unchanged() {
562 let q = "changes exports (a, b, c, d imported by 2 files";
563 assert_eq!(cap_member_list(q, 2), q);
564 }
565
566 #[test]
567 fn an_unclosed_parenthesis_in_the_path_does_not_stop_the_scan() {
568 let q = "`src/a(b.ts` changes exports (a, b, c, d, e) imported by 2 files";
569 assert_eq!(
570 cap_member_list(q, 3),
571 "`src/a(b.ts` changes exports (a, b, c, +2 more) imported by 2 files"
572 );
573 }
574
575 #[test]
576 fn a_cap_of_zero_leaves_the_text_unchanged() {
577 let q = "changes exports (a, b, c) imported by 2 files";
578 assert_eq!(cap_member_list(q, 0), q);
579 }
580
581 #[test]
582 fn non_anchor_path_is_kept_but_question_dropped() {
583 let q = "`ui` now imports `db` for the first time. Intended coupling, or should this edge not exist?";
586 let out = clean_decision_fact(q, "src/ui/page.ts", 6);
587 assert_eq!(out, "`ui` now imports `db` for the first time.");
588 }
589
590 #[test]
591 fn public_api_surface_question_drops_to_one_sentence() {
592 let q = "This change adds 3 exports to the public API surface. Intended as maintained contracts, or should they stay internal?";
596 let out = clean_decision_fact(q, "src/lib/id.ts", 6);
597 assert_eq!(out, "This change adds 3 exports to the public API surface.");
598 }
599
600 #[test]
601 fn cap_names_first_k_then_more() {
602 let names = vec![
603 "a".to_string(),
604 "b".to_string(),
605 "c".to_string(),
606 "d".to_string(),
607 ];
608 let (shown, more) = cap_names(&names, 2);
609 assert_eq!(shown, vec!["a", "b"]);
610 assert_eq!(more, 2);
611 }
612}