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