1pub use fallow_output::{
36 AcceptedJudgment, AgentWalkthrough, ChangeAnchor, DirectionUnit, INJECTION_NOTE,
37 RejectedJudgment, ReviewDirection, WalkthroughValidation, agent_schema,
38};
39use fallow_output::{FocusMap, RoutingFacts};
40use rustc_hash::{FxHashMap, FxHashSet};
41use xxhash_rust::xxh3::xxh3_64;
42
43use crate::audit_brief::{ReviewBriefOutput, ReviewBriefSchemaVersion, build_brief_output};
44use crate::audit_decision_surface::DecisionSurface;
45use crate::report::ci::diff_filter::parse_new_hunk_start;
46
47#[cfg(test)]
48use fallow_output::AgentJudgment;
49
50const UNANCHORED_REASON: &str = "unanchored-signal-id";
53
54const UNKNOWN_CHANGE_ANCHOR_REASON: &str = "unknown-change-anchor";
58
59pub type WalkthroughGuide = fallow_output::StandardWalkthroughGuide;
60
61fn normalize_added_text(lines: &[String]) -> String {
64 lines
65 .iter()
66 .map(|l| l.trim())
67 .collect::<Vec<_>>()
68 .join("\n")
69}
70
71#[must_use]
77pub fn derive_change_anchor_id(path: &str, normalized_added_text: &str, ordinal: u32) -> String {
78 let mut bytes =
79 Vec::with_capacity(path.len() + 1 + normalized_added_text.len() + 1 + size_of::<u32>());
80 bytes.extend_from_slice(path.as_bytes());
81 bytes.push(0);
82 bytes.extend_from_slice(normalized_added_text.as_bytes());
83 bytes.push(0);
84 bytes.extend_from_slice(&ordinal.to_le_bytes());
85 format!("chg:{:016x}", xxh3_64(&bytes))
86}
87
88#[derive(Default)]
92struct AnchorParser {
93 anchors: Vec<ChangeAnchor>,
94 seen: FxHashMap<(String, String), u32>,
96 current_file: Option<String>,
97 rename_from: Option<String>,
98 pending_rename_from: Option<String>,
99 start_line: u64,
100 hunk_lines: Vec<String>,
101 in_hunk: bool,
102}
103
104impl AnchorParser {
105 fn flush(&mut self) {
110 if let Some(file) = self.current_file.clone()
111 && !self.hunk_lines.is_empty()
112 {
113 let normalized = normalize_added_text(&self.hunk_lines);
114 let counter = self
115 .seen
116 .entry((file.clone(), normalized.clone()))
117 .or_insert(0);
118 let ordinal = *counter;
119 *counter += 1;
120 let change_anchor = derive_change_anchor_id(&file, &normalized, ordinal);
121 let previous_change_anchor = self
122 .rename_from
123 .as_deref()
124 .map(|old| derive_change_anchor_id(old, &normalized, ordinal));
125 self.anchors.push(ChangeAnchor {
126 change_anchor,
127 file,
128 start_line: u32::try_from(self.start_line).unwrap_or(u32::MAX),
129 line_count: u32::try_from(self.hunk_lines.len()).unwrap_or(u32::MAX),
130 previous_change_anchor,
131 });
132 }
133 self.hunk_lines.clear();
134 }
135
136 fn consume(&mut self, line: &str) {
140 if line.starts_with("diff --git ") {
141 self.flush();
142 self.in_hunk = false;
143 self.current_file = None;
144 self.rename_from = None;
145 self.pending_rename_from = None;
146 return;
147 }
148 if let Some(rest) = line.strip_prefix("rename from ") {
149 self.pending_rename_from = Some(rest.to_owned());
150 return;
151 }
152 if let Some(rest) = line.strip_prefix("rename to ") {
153 if let Some(from) = self.pending_rename_from.take() {
154 self.current_file = Some(rest.to_owned());
155 self.rename_from = Some(from);
156 }
157 return;
158 }
159 if let Some(path) = line.strip_prefix("+++ b/") {
160 self.flush();
161 self.in_hunk = false;
162 self.current_file = Some(path.to_owned());
163 return;
164 }
165 if line.starts_with("+++ /dev/null") {
166 self.flush();
167 self.in_hunk = false;
168 self.current_file = None;
169 return;
170 }
171 if let Some(header) = line.strip_prefix("@@ ") {
172 self.flush();
173 self.start_line = parse_new_hunk_start(header).unwrap_or(0);
174 self.in_hunk = true;
175 return;
176 }
177 if self.in_hunk
178 && self.current_file.is_some()
179 && line.starts_with('+')
180 && !line.starts_with("+++")
181 {
182 self.hunk_lines.push(line[1..].to_owned());
183 }
184 }
185}
186
187#[must_use]
192pub fn parse_change_anchors(diff: &str) -> Vec<ChangeAnchor> {
193 let mut parser = AnchorParser::default();
194 for line in diff.lines() {
195 parser.consume(line);
196 }
197 parser.flush();
198 parser.anchors
199}
200
201#[must_use]
205pub fn change_anchor_allowlist(anchors: &[ChangeAnchor]) -> FxHashSet<String> {
206 let mut set = FxHashSet::default();
207 for anchor in anchors {
208 set.insert(anchor.change_anchor.clone());
209 if let Some(previous) = &anchor.previous_change_anchor {
210 set.insert(previous.clone());
211 }
212 }
213 set
214}
215
216fn is_reviewable_source_unit(file: &str) -> bool {
221 matches!(
222 std::path::Path::new(file)
223 .extension()
224 .and_then(|e| e.to_str()),
225 Some(
226 "ts" | "tsx"
227 | "js"
228 | "jsx"
229 | "mjs"
230 | "cjs"
231 | "mts"
232 | "cts"
233 | "gts"
234 | "gjs"
235 | "vue"
236 | "svelte"
237 | "astro"
238 )
239 )
240}
241
242fn parse_fan_counts(reason: &str) -> (u32, u32) {
252 let fan_in = extract_count(reason, "high fan-in (");
253 let fan_out = extract_count(reason, "fan-out ");
254 (fan_in, fan_out)
255}
256
257fn extract_count(text: &str, marker: &str) -> u32 {
260 let Some(rest) = text.find(marker).map(|i| &text[i + marker.len()..]) else {
261 return 0;
262 };
263 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
264 digits.parse().unwrap_or(0)
265}
266
267#[allow(
278 clippy::implicit_hasher,
279 reason = "fallow standardizes on FxHashMap; fires on the lib target only, so #[expect] is unfulfilled on the bin"
280)]
281#[must_use]
282pub fn build_direction(
283 focus: &FocusMap,
284 out_of_diff_by_file: &FxHashMap<String, Vec<String>>,
285 routing: &RoutingFacts,
286) -> ReviewDirection {
287 let expert_by_file: FxHashMap<&str, &[String]> = routing
290 .units
291 .iter()
292 .map(|unit| (unit.file.as_str(), unit.expert.as_slice()))
293 .collect();
294
295 let mut units: Vec<DirectionUnit> = focus
296 .review_here
297 .iter()
298 .chain(focus.deprioritized.iter())
299 .filter(|unit| is_reviewable_source_unit(&unit.file))
300 .map(|unit| {
301 let out_of_diff = out_of_diff_by_file
305 .get(&unit.file)
306 .cloned()
307 .unwrap_or_default();
308 let concern_lens = if out_of_diff.is_empty() {
309 "orientation".to_string()
310 } else {
311 "contract-break".to_string()
312 };
313 DirectionUnit {
314 file: unit.file.clone(),
315 concern_lens,
316 scoring_budget: unit.score.total,
317 out_of_diff,
318 expert: expert_by_file
319 .get(unit.file.as_str())
320 .map(|experts| experts.to_vec())
321 .unwrap_or_default(),
322 }
323 })
324 .collect();
325
326 let fan_counts_by_file: FxHashMap<&str, (u32, u32)> = focus
337 .review_here
338 .iter()
339 .chain(focus.deprioritized.iter())
340 .map(|fu| (fu.file.as_str(), parse_fan_counts(&fu.reason)))
341 .collect();
342 let fan_counts =
343 |file: &str| -> (u32, u32) { fan_counts_by_file.get(file).copied().unwrap_or((0, 0)) };
344 units.sort_by(|a, b| {
345 let (a_in, a_out) = fan_counts(&a.file);
346 let (b_in, b_out) = fan_counts(&b.file);
347 b.out_of_diff
348 .len()
349 .cmp(&a.out_of_diff.len())
350 .then_with(|| b_in.cmp(&a_in))
351 .then_with(|| b_out.cmp(&a_out))
352 .then_with(|| a.file.cmp(&b.file))
353 });
354
355 let order = units.iter().map(|u| u.file.clone()).collect();
356 ReviewDirection { order, units }
357}
358
359#[must_use]
362pub fn build_walkthrough_guide(
363 digest: ReviewBriefOutput,
364 graph_snapshot_hash: String,
365 direction: ReviewDirection,
366 change_anchors: Vec<ChangeAnchor>,
367) -> WalkthroughGuide {
368 WalkthroughGuide {
369 schema_version: ReviewBriefSchemaVersion::default(),
370 version: env!("CARGO_PKG_VERSION").to_string(),
371 command: "review-walkthrough-guide".to_string(),
372 graph_snapshot_hash,
373 digest,
374 direction,
375 change_anchors,
376 agent_schema: agent_schema(),
377 injection_note: INJECTION_NOTE,
378 }
379}
380
381#[must_use]
392#[allow(
393 clippy::implicit_hasher,
394 reason = "fallow standardizes on FxHashSet; the change-anchor allowlist is always built with the fallow hasher"
395)]
396pub fn validate_walkthrough(
397 agent: &AgentWalkthrough,
398 surface: &DecisionSurface,
399 change_anchor_ids: &FxHashSet<String>,
400 current_hash: &str,
401) -> WalkthroughValidation {
402 let stale = agent.graph_snapshot_hash != current_hash;
403
404 let mut accepted: Vec<AcceptedJudgment> = Vec::new();
405 let mut rejected: Vec<RejectedJudgment> = Vec::new();
406
407 if stale {
408 for judgment in &agent.judgments {
411 rejected.push(RejectedJudgment {
412 signal_id: judgment.signal_id.clone(),
413 change_anchor: judgment.change_anchor.clone(),
414 reason: "stale-snapshot".to_string(),
415 });
416 }
417 } else {
418 for judgment in &agent.judgments {
419 if !judgment.signal_id.is_empty() && surface.accept_signal_id(&judgment.signal_id) {
422 accepted.push(AcceptedJudgment {
423 signal_id: judgment.signal_id.clone(),
424 change_anchor: String::new(),
425 anchor_kind: "signal".to_string(),
426 agent_framing: judgment.framing.clone(),
427 concern: judgment.concern.clone(),
428 deterministic: false,
429 });
430 } else if !judgment.change_anchor.is_empty()
431 && change_anchor_ids.contains(&judgment.change_anchor)
432 {
433 accepted.push(AcceptedJudgment {
434 signal_id: String::new(),
435 change_anchor: judgment.change_anchor.clone(),
436 anchor_kind: "change".to_string(),
437 agent_framing: judgment.framing.clone(),
438 concern: judgment.concern.clone(),
439 deterministic: false,
440 });
441 } else {
442 let reason = if judgment.signal_id.is_empty() && !judgment.change_anchor.is_empty()
445 {
446 UNKNOWN_CHANGE_ANCHOR_REASON
447 } else {
448 UNANCHORED_REASON
449 };
450 rejected.push(RejectedJudgment {
451 signal_id: judgment.signal_id.clone(),
452 change_anchor: judgment.change_anchor.clone(),
453 reason: reason.to_string(),
454 });
455 }
456 }
457 }
458
459 let accepted_count = accepted.len();
460 let rejected_count = rejected.len();
461 let unanchored_count = 0;
465
466 WalkthroughValidation {
467 schema_version: ReviewBriefSchemaVersion::default(),
468 version: env!("CARGO_PKG_VERSION").to_string(),
469 command: "review-walkthrough-validation".to_string(),
470 graph_snapshot_hash: current_hash.to_string(),
471 stale,
472 accepted,
473 rejected,
474 accepted_count,
475 rejected_count,
476 unanchored_count,
477 }
478}
479
480#[must_use]
485pub fn parse_agent_walkthrough(contents: &str) -> AgentWalkthrough {
486 serde_json::from_str(contents).unwrap_or_else(|_| AgentWalkthrough {
487 graph_snapshot_hash: String::new(),
488 judgments: Vec::new(),
489 })
490}
491
492#[must_use]
496pub fn build_guide_from_result(result: &crate::audit::AuditResult) -> WalkthroughGuide {
497 let digest = build_brief_output(result);
498 let hash = result.graph_snapshot_hash.clone().unwrap_or_default();
499 let empty_routing = RoutingFacts::default();
500 let routing = result.routing.as_ref().unwrap_or(&empty_routing);
501 let mut out_of_diff_by_file: FxHashMap<String, Vec<String>> = FxHashMap::default();
505 if let Some(closure) = result
506 .check
507 .as_ref()
508 .and_then(|c| c.impact_closure.as_ref())
509 {
510 for gap in &closure.coordination_gap {
511 out_of_diff_by_file
512 .entry(gap.changed_file.clone())
513 .or_default()
514 .push(gap.consumer_file.clone());
515 }
516 for consumers in out_of_diff_by_file.values_mut() {
517 consumers.sort();
518 consumers.dedup();
519 }
520 }
521 let direction = build_direction(&digest.focus, &out_of_diff_by_file, routing);
525 build_walkthrough_guide(digest, hash, direction, result.change_anchors.clone())
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::audit::routing::RoutingUnit;
532 use crate::audit_brief::ReviewDeltas;
533 use crate::audit_decision_surface::{
534 BoundaryAnchor, DecisionCategory, DecisionInputs, derive_signal_id,
535 extract_decision_surface,
536 };
537
538 fn no_source(_: &str) -> Option<String> {
539 None
540 }
541
542 fn surface_with_one_signal() -> (DecisionSurface, String) {
545 let deltas = ReviewDeltas {
546 boundary_introduced: vec!["ui->-db".to_string()],
547 cycle_introduced: Vec::new(),
548 public_api_added: Vec::new(),
549 };
550 let anchors = vec![BoundaryAnchor {
551 zone_pair_key: "ui->-db".to_string(),
552 from_file: "src/ui/page.ts".to_string(),
553 from_zone: "ui".to_string(),
554 to_zone: "db".to_string(),
555 line: 1,
556 }];
557 let routing = RoutingFacts::default();
558 let surface = extract_decision_surface(&DecisionInputs {
559 deltas: &deltas,
560 boundary_anchors: &anchors,
561 coordination: &[],
562 public_api_anchor_line: 0,
563 affected_not_shown: 3,
564 routing: &routing,
565 head_source: &no_source,
566 rename_old_path: &no_source,
567 internal_consumers: &|_: &str| 0u64,
568 cap: 4,
569 });
570 let real_id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
571 (surface, real_id)
572 }
573
574 #[test]
577 fn clean_agent_json_is_accepted_with_zero_unanchored() {
578 let (surface, real_id) = surface_with_one_signal();
579 let hash = "graph:abc123";
580 let agent = AgentWalkthrough {
581 graph_snapshot_hash: hash.to_string(),
582 judgments: vec![AgentJudgment {
583 signal_id: real_id.clone(),
584 change_anchor: String::new(),
585 framing: "Intended coupling, payments boundary widened on purpose.".to_string(),
586 concern: Some("coupling".to_string()),
587 }],
588 };
589 let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
590 assert!(!validation.stale, "matching hash is not stale");
591 assert_eq!(
592 validation.accepted_count, 1,
593 "the anchored judgment accepts"
594 );
595 assert_eq!(validation.rejected_count, 0, "no rejections");
596 assert_eq!(validation.unanchored_count, 0, "zero unanchored findings");
597 assert!(!validation.accepted[0].deterministic);
599 assert_eq!(validation.accepted[0].signal_id, real_id);
600 }
601
602 #[test]
604 fn injected_unanchored_signal_id_is_rejected() {
605 let (surface, real_id) = surface_with_one_signal();
606 let hash = "graph:abc123";
607 let agent = AgentWalkthrough {
608 graph_snapshot_hash: hash.to_string(),
609 judgments: vec![
610 AgentJudgment {
611 signal_id: real_id.clone(),
612 change_anchor: String::new(),
613 framing: "real".to_string(),
614 concern: None,
615 },
616 AgentJudgment {
617 signal_id: "sig:deadbeefdeadbeef".to_string(),
619 change_anchor: String::new(),
620 framing: "hallucinated decision with no graph anchor".to_string(),
621 concern: None,
622 },
623 ],
624 };
625 let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
626 assert_eq!(validation.accepted_count, 1, "only the real one accepts");
627 assert_eq!(validation.rejected_count, 1, "the fabricated one rejects");
628 assert_eq!(validation.rejected[0].signal_id, "sig:deadbeefdeadbeef");
629 assert_eq!(validation.rejected[0].reason, UNANCHORED_REASON);
630 assert!(
632 validation.accepted.iter().all(|j| j.signal_id == real_id),
633 "accepted excludes the unanchored id"
634 );
635 }
636
637 #[test]
639 fn stale_snapshot_hash_refuses_the_whole_payload() {
640 let (surface, real_id) = surface_with_one_signal();
641 let current_hash = "graph:NEW_after_mutation";
642 let agent = AgentWalkthrough {
644 graph_snapshot_hash: "graph:OLD_before_mutation".to_string(),
645 judgments: vec![AgentJudgment {
646 signal_id: real_id,
648 change_anchor: String::new(),
649 framing: "would be valid, but the tree moved".to_string(),
650 concern: None,
651 }],
652 };
653 let validation =
654 validate_walkthrough(&agent, &surface, &FxHashSet::default(), current_hash);
655 assert!(validation.stale, "old hash is stale");
656 assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
657 assert_eq!(validation.rejected_count, 1, "the judgment is refused");
658 assert_eq!(validation.rejected[0].reason, "stale-snapshot");
659 }
660
661 #[test]
662 fn malformed_agent_json_parses_to_a_stale_refusal() {
663 let agent = parse_agent_walkthrough("{not valid json");
664 assert!(agent.graph_snapshot_hash.is_empty());
665 assert!(agent.judgments.is_empty());
666 let (surface, _) = surface_with_one_signal();
667 let validation =
668 validate_walkthrough(&agent, &surface, &FxHashSet::default(), "graph:real");
669 assert!(
670 validation.stale,
671 "empty echoed hash never matches a real hash"
672 );
673 assert_eq!(validation.accepted_count, 0);
674 }
675
676 fn focus_unit(file: &str, total: u32) -> crate::audit_focus::FocusUnit {
677 crate::audit_focus::FocusUnit {
678 file: file.to_string(),
679 score: crate::audit_focus::FocusScore {
680 total,
681 ..Default::default()
682 },
683 label: crate::audit_focus::FocusLabel::ReviewHere,
684 reason: String::new(),
685 confidence: Vec::new(),
686 }
687 }
688
689 fn focus_unit_fan(
694 file: &str,
695 importers: u32,
696 fan_io: u32,
697 total: u32,
698 ) -> crate::audit_focus::FocusUnit {
699 let s = if importers == 1 { "" } else { "s" };
700 crate::audit_focus::FocusUnit {
701 file: file.to_string(),
702 score: crate::audit_focus::FocusScore {
703 fan_io,
704 total,
705 ..Default::default()
706 },
707 label: crate::audit_focus::FocusLabel::ReviewHere,
708 reason: format!("high fan-in ({importers} importer{s})"),
709 confidence: Vec::new(),
710 }
711 }
712
713 #[test]
720 fn within_stage_order_follows_visible_fan_io_not_hidden_total() {
721 let focus = FocusMap {
722 review_here: vec![
723 focus_unit_fan("src/a.ts", 5, 8, 99),
725 focus_unit_fan("src/b.ts", 60, 8, 1),
727 ],
728 deprioritized: vec![],
729 };
730 let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
731 assert_eq!(direction.order, vec!["src/b.ts", "src/a.ts"]);
734 }
735
736 #[test]
739 fn stage_two_orders_by_importer_then_fanout_then_path() {
740 let focus = FocusMap {
741 review_here: vec![
742 crate::audit_focus::FocusUnit {
745 file: "src/zlo.ts".to_string(),
746 score: crate::audit_focus::FocusScore::default(),
747 label: crate::audit_focus::FocusLabel::ReviewHere,
748 reason: "fan-out 1".to_string(),
749 confidence: Vec::new(),
750 },
751 crate::audit_focus::FocusUnit {
752 file: "src/zhi.ts".to_string(),
753 score: crate::audit_focus::FocusScore::default(),
754 label: crate::audit_focus::FocusLabel::ReviewHere,
755 reason: "fan-out 9".to_string(),
756 confidence: Vec::new(),
757 },
758 focus_unit_fan("src/top.ts", 12, 8, 1),
759 ],
760 deprioritized: vec![],
761 };
762 let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
763 assert_eq!(
766 direction.order,
767 vec!["src/top.ts", "src/zhi.ts", "src/zlo.ts"]
768 );
769 }
770
771 #[test]
775 fn out_of_diff_units_sort_ahead_of_higher_fan_io_orientation_units() {
776 let focus = FocusMap {
777 review_here: vec![
778 focus_unit_fan("src/contract.ts", 1, 1, 1),
780 focus_unit_fan("src/orient.ts", 9, 9, 9),
781 ],
782 deprioritized: vec![],
783 };
784 let mut out_of_diff = FxHashMap::default();
785 out_of_diff.insert(
786 "src/contract.ts".to_string(),
787 vec!["src/consumer.ts".to_string()],
788 );
789 let direction = build_direction(&focus, &out_of_diff, &RoutingFacts::default());
790 assert_eq!(direction.order, vec!["src/contract.ts", "src/orient.ts"]);
793 assert_eq!(direction.units[0].concern_lens, "contract-break");
794 }
795
796 #[test]
797 fn direction_spines_on_focus_units_with_expert_overlay() {
798 let focus = FocusMap {
802 review_here: vec![focus_unit("src/b.ts", 5), focus_unit("src/a.ts", 3)],
803 deprioritized: vec![],
804 };
805 let routing = RoutingFacts {
806 units: vec![RoutingUnit {
807 file: "src/b.ts".to_string(),
808 expert: vec!["@team".to_string()],
809 bus_factor_one: false,
810 }],
811 };
812 let mut out_of_diff_by_file = FxHashMap::default();
814 out_of_diff_by_file.insert("src/a.ts".to_string(), vec!["src/consumer.ts".to_string()]);
815 let direction = build_direction(&focus, &out_of_diff_by_file, &routing);
816 assert_eq!(direction.order, vec!["src/a.ts", "src/b.ts"]);
820 assert_eq!(direction.units[0].file, "src/a.ts");
821 assert_eq!(direction.units[0].concern_lens, "contract-break");
822 assert_eq!(direction.units[0].out_of_diff, vec!["src/consumer.ts"]);
823 assert_eq!(direction.units[0].scoring_budget, 3);
824 assert!(direction.units[0].expert.is_empty());
825 assert_eq!(direction.units[1].file, "src/b.ts");
826 assert_eq!(direction.units[1].concern_lens, "orientation");
827 assert_eq!(direction.units[1].scoring_budget, 5);
828 assert_eq!(direction.units[1].expert, vec!["@team".to_string()]);
829 }
830
831 #[test]
832 fn direction_excludes_non_source_units() {
833 let focus = FocusMap {
834 review_here: vec![
835 focus_unit("LICENSE", 1),
836 focus_unit(".gitignore", 1),
837 focus_unit("README.md", 1),
838 focus_unit("src/app.component.ts", 4),
839 ],
840 deprioritized: vec![],
841 };
842 let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
843 assert_eq!(direction.order, vec!["src/app.component.ts"]);
845 assert_eq!(direction.units[0].concern_lens, "orientation");
846 assert_eq!(direction.units[0].scoring_budget, 4);
847 }
848
849 #[test]
850 fn guide_carries_the_snapshot_hash_and_injection_note() {
851 let digest = ReviewBriefOutput {
852 schema_version: ReviewBriefSchemaVersion::default(),
853 version: "test".to_string(),
854 command: "audit-brief".to_string(),
855 triage: crate::audit_brief::DiffTriage {
856 files: 0,
857 hunks: None,
858 net_lines: None,
859 risk_class: crate::audit_brief::RiskClass::Low,
860 review_effort: crate::audit_brief::ReviewEffort::Glance,
861 },
862 graph_facts: crate::audit_brief::GraphFacts {
863 exports_added: 0,
864 api_width_delta: 0,
865 reachable_from: Vec::new(),
866 boundaries_touched: Vec::new(),
867 },
868 partition: crate::audit_brief::PartitionFacts::default(),
869 impact_closure: crate::audit_brief::ImpactClosureFacts::default(),
870 focus: crate::audit_focus::FocusMap::default(),
871 deltas: ReviewDeltas::default(),
872 weakening: Vec::new(),
873 routing: RoutingFacts::default(),
874 decisions: DecisionSurface::default(),
875 };
876 let guide = build_walkthrough_guide(
877 digest,
878 "graph:pinned".to_string(),
879 ReviewDirection::default(),
880 Vec::new(),
881 );
882 assert_eq!(guide.graph_snapshot_hash, "graph:pinned");
883 assert!(guide.injection_note.contains("untrusted"));
884 assert_eq!(guide.command, "review-walkthrough-guide");
885 assert!(guide.agent_schema.anchoring_rule.contains("rejected"));
886 }
887
888 #[test]
891 fn derive_change_anchor_id_is_stable_and_namespaced() {
892 let added = vec!["const x = 1;".to_string(), "return x;".to_string()];
893 let normalized = normalize_added_text(&added);
894 let id = derive_change_anchor_id("src/a.ts", &normalized, 0);
895 assert!(id.starts_with("chg:"), "namespaced under chg:");
896 assert_eq!(id, derive_change_anchor_id("src/a.ts", &normalized, 0));
898 let reflowed = vec![" const x = 1; ".to_string(), "\treturn x;".to_string()];
900 assert_eq!(
901 id,
902 derive_change_anchor_id("src/a.ts", &normalize_added_text(&reflowed), 0)
903 );
904 assert_ne!(
906 id,
907 derive_change_anchor_id(
908 "src/a.ts",
909 &normalize_added_text(&["const y = 2;".to_string()]),
910 0
911 )
912 );
913 assert_ne!(id, derive_change_anchor_id("src/b.ts", &normalized, 0));
915 }
916
917 #[test]
920 fn parse_change_anchors_is_line_shift_stable() {
921 let diff_a = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -10,0 +11,1 @@\n+ const added = compute();\n";
922 let diff_b = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -40,0 +41,1 @@\n+ const added = compute();\n";
923 let a = parse_change_anchors(diff_a);
924 let b = parse_change_anchors(diff_b);
925 assert_eq!(a.len(), 1);
926 assert_eq!(b.len(), 1);
927 assert_eq!(
928 a[0].change_anchor, b[0].change_anchor,
929 "id is line-shift stable"
930 );
931 assert_eq!(a[0].start_line, 11);
932 assert_eq!(b[0].start_line, 41, "start_line tracks the new position");
933 }
934
935 #[test]
938 fn change_anchor_judgment_accepts_and_unknown_rejects() {
939 let (surface, _) = surface_with_one_signal();
940 let hash = "graph:abc123";
941 let diff = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1,0 +2,1 @@\n+ const added = compute();\n";
942 let anchors = parse_change_anchors(diff);
943 let allow = change_anchor_allowlist(&anchors);
944 let real = anchors[0].change_anchor.clone();
945 let agent = AgentWalkthrough {
946 graph_snapshot_hash: hash.to_string(),
947 judgments: vec![
948 AgentJudgment {
949 signal_id: String::new(),
950 change_anchor: real.clone(),
951 framing: "this region trades simplicity for a cache".to_string(),
952 concern: None,
953 },
954 AgentJudgment {
955 signal_id: String::new(),
956 change_anchor: "chg:deadbeefdeadbeef".to_string(),
957 framing: "hallucinated region".to_string(),
958 concern: None,
959 },
960 ],
961 };
962 let validation = validate_walkthrough(&agent, &surface, &allow, hash);
963 assert_eq!(
964 validation.accepted_count, 1,
965 "the real change_anchor accepts"
966 );
967 assert_eq!(validation.accepted[0].anchor_kind, "change");
968 assert_eq!(validation.accepted[0].change_anchor, real);
969 assert!(validation.accepted[0].signal_id.is_empty());
970 assert!(!validation.accepted[0].deterministic);
971 assert_eq!(
972 validation.rejected_count, 1,
973 "the fabricated region rejects"
974 );
975 assert_eq!(validation.rejected[0].reason, UNKNOWN_CHANGE_ANCHOR_REASON);
976 assert_eq!(validation.rejected[0].change_anchor, "chg:deadbeefdeadbeef");
977 }
978
979 #[test]
981 fn stale_snapshot_refuses_change_anchor_judgment() {
982 let (surface, _) = surface_with_one_signal();
983 let diff = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1,0 +2,1 @@\n+ const added = compute();\n";
984 let anchors = parse_change_anchors(diff);
985 let allow = change_anchor_allowlist(&anchors);
986 let agent = AgentWalkthrough {
987 graph_snapshot_hash: "graph:OLD".to_string(),
988 judgments: vec![AgentJudgment {
989 signal_id: String::new(),
990 change_anchor: anchors[0].change_anchor.clone(),
991 framing: "valid region, but the tree moved".to_string(),
992 concern: None,
993 }],
994 };
995 let validation = validate_walkthrough(&agent, &surface, &allow, "graph:NEW");
996 assert!(validation.stale);
997 assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
998 assert_eq!(validation.rejected[0].reason, "stale-snapshot");
999 }
1000
1001 #[test]
1004 fn change_anchor_survives_rename_via_previous_anchor() {
1005 let renamed = "diff --git a/src/old.ts b/src/new.ts\nrename from src/old.ts\nrename to src/new.ts\n--- a/src/old.ts\n+++ b/src/new.ts\n@@ -1,0 +2,1 @@\n+ const added = compute();\n";
1006 let anchors = parse_change_anchors(renamed);
1007 assert_eq!(anchors.len(), 1);
1008 assert_eq!(anchors[0].file, "src/new.ts");
1009 let previous = anchors[0]
1010 .previous_change_anchor
1011 .clone()
1012 .expect("rename yields a previous anchor");
1013 let old_diff = "diff --git a/src/old.ts b/src/old.ts\n--- a/src/old.ts\n+++ b/src/old.ts\n@@ -1,0 +2,1 @@\n+ const added = compute();\n";
1015 let old_anchors = parse_change_anchors(old_diff);
1016 assert_eq!(previous, old_anchors[0].change_anchor);
1017 let allow = change_anchor_allowlist(&anchors);
1019 assert!(
1020 allow.contains(&previous),
1021 "pre-rename id is in the allowlist"
1022 );
1023 assert!(allow.contains(&anchors[0].change_anchor));
1024 }
1025}