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
242#[allow(
253 clippy::implicit_hasher,
254 reason = "fallow standardizes on FxHashMap; fires on the lib target only, so #[expect] is unfulfilled on the bin"
255)]
256#[must_use]
257pub fn build_direction(
258 focus: &FocusMap,
259 out_of_diff_by_file: &FxHashMap<String, Vec<String>>,
260 routing: &RoutingFacts,
261) -> ReviewDirection {
262 let expert_by_file: FxHashMap<&str, &[String]> = routing
265 .units
266 .iter()
267 .map(|unit| (unit.file.as_str(), unit.expert.as_slice()))
268 .collect();
269
270 let mut units: Vec<DirectionUnit> = focus
271 .review_here
272 .iter()
273 .chain(focus.deprioritized.iter())
274 .filter(|unit| is_reviewable_source_unit(&unit.file))
275 .map(|unit| {
276 let out_of_diff = out_of_diff_by_file
280 .get(&unit.file)
281 .cloned()
282 .unwrap_or_default();
283 let concern_lens = if out_of_diff.is_empty() {
284 "orientation".to_string()
285 } else {
286 "contract-break".to_string()
287 };
288 DirectionUnit {
289 file: unit.file.clone(),
290 concern_lens,
291 scoring_budget: unit.score.total,
292 out_of_diff,
293 expert: expert_by_file
294 .get(unit.file.as_str())
295 .map(|experts| experts.to_vec())
296 .unwrap_or_default(),
297 }
298 })
299 .collect();
300
301 units.sort_by(|a, b| {
304 b.out_of_diff
305 .len()
306 .cmp(&a.out_of_diff.len())
307 .then_with(|| b.scoring_budget.cmp(&a.scoring_budget))
308 .then_with(|| a.file.cmp(&b.file))
309 });
310
311 let order = units.iter().map(|u| u.file.clone()).collect();
312 ReviewDirection { order, units }
313}
314
315#[must_use]
318pub fn build_walkthrough_guide(
319 digest: ReviewBriefOutput,
320 graph_snapshot_hash: String,
321 direction: ReviewDirection,
322 change_anchors: Vec<ChangeAnchor>,
323) -> WalkthroughGuide {
324 WalkthroughGuide {
325 schema_version: ReviewBriefSchemaVersion::default(),
326 version: env!("CARGO_PKG_VERSION").to_string(),
327 command: "review-walkthrough-guide".to_string(),
328 graph_snapshot_hash,
329 digest,
330 direction,
331 change_anchors,
332 agent_schema: agent_schema(),
333 injection_note: INJECTION_NOTE,
334 }
335}
336
337#[must_use]
348#[allow(
349 clippy::implicit_hasher,
350 reason = "fallow standardizes on FxHashSet; the change-anchor allowlist is always built with the fallow hasher"
351)]
352pub fn validate_walkthrough(
353 agent: &AgentWalkthrough,
354 surface: &DecisionSurface,
355 change_anchor_ids: &FxHashSet<String>,
356 current_hash: &str,
357) -> WalkthroughValidation {
358 let stale = agent.graph_snapshot_hash != current_hash;
359
360 let mut accepted: Vec<AcceptedJudgment> = Vec::new();
361 let mut rejected: Vec<RejectedJudgment> = Vec::new();
362
363 if stale {
364 for judgment in &agent.judgments {
367 rejected.push(RejectedJudgment {
368 signal_id: judgment.signal_id.clone(),
369 change_anchor: judgment.change_anchor.clone(),
370 reason: "stale-snapshot".to_string(),
371 });
372 }
373 } else {
374 for judgment in &agent.judgments {
375 if !judgment.signal_id.is_empty() && surface.accept_signal_id(&judgment.signal_id) {
378 accepted.push(AcceptedJudgment {
379 signal_id: judgment.signal_id.clone(),
380 change_anchor: String::new(),
381 anchor_kind: "signal".to_string(),
382 agent_framing: judgment.framing.clone(),
383 concern: judgment.concern.clone(),
384 deterministic: false,
385 });
386 } else if !judgment.change_anchor.is_empty()
387 && change_anchor_ids.contains(&judgment.change_anchor)
388 {
389 accepted.push(AcceptedJudgment {
390 signal_id: String::new(),
391 change_anchor: judgment.change_anchor.clone(),
392 anchor_kind: "change".to_string(),
393 agent_framing: judgment.framing.clone(),
394 concern: judgment.concern.clone(),
395 deterministic: false,
396 });
397 } else {
398 let reason = if judgment.signal_id.is_empty() && !judgment.change_anchor.is_empty()
401 {
402 UNKNOWN_CHANGE_ANCHOR_REASON
403 } else {
404 UNANCHORED_REASON
405 };
406 rejected.push(RejectedJudgment {
407 signal_id: judgment.signal_id.clone(),
408 change_anchor: judgment.change_anchor.clone(),
409 reason: reason.to_string(),
410 });
411 }
412 }
413 }
414
415 let accepted_count = accepted.len();
416 let rejected_count = rejected.len();
417 let unanchored_count = 0;
421
422 WalkthroughValidation {
423 schema_version: ReviewBriefSchemaVersion::default(),
424 version: env!("CARGO_PKG_VERSION").to_string(),
425 command: "review-walkthrough-validation".to_string(),
426 graph_snapshot_hash: current_hash.to_string(),
427 stale,
428 accepted,
429 rejected,
430 accepted_count,
431 rejected_count,
432 unanchored_count,
433 }
434}
435
436#[must_use]
441pub fn parse_agent_walkthrough(contents: &str) -> AgentWalkthrough {
442 serde_json::from_str(contents).unwrap_or_else(|_| AgentWalkthrough {
443 graph_snapshot_hash: String::new(),
444 judgments: Vec::new(),
445 })
446}
447
448#[must_use]
452pub fn build_guide_from_result(result: &crate::audit::AuditResult) -> WalkthroughGuide {
453 let digest = build_brief_output(result);
454 let hash = result.graph_snapshot_hash.clone().unwrap_or_default();
455 let empty_routing = RoutingFacts::default();
456 let routing = result.routing.as_ref().unwrap_or(&empty_routing);
457 let mut out_of_diff_by_file: FxHashMap<String, Vec<String>> = FxHashMap::default();
461 if let Some(closure) = result
462 .check
463 .as_ref()
464 .and_then(|c| c.impact_closure.as_ref())
465 {
466 for gap in &closure.coordination_gap {
467 out_of_diff_by_file
468 .entry(gap.changed_file.clone())
469 .or_default()
470 .push(gap.consumer_file.clone());
471 }
472 for consumers in out_of_diff_by_file.values_mut() {
473 consumers.sort();
474 consumers.dedup();
475 }
476 }
477 let direction = build_direction(&digest.focus, &out_of_diff_by_file, routing);
481 build_walkthrough_guide(digest, hash, direction, result.change_anchors.clone())
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::audit::routing::RoutingUnit;
488 use crate::audit_brief::ReviewDeltas;
489 use crate::audit_decision_surface::{
490 BoundaryAnchor, DecisionCategory, DecisionInputs, derive_signal_id,
491 extract_decision_surface,
492 };
493
494 fn no_source(_: &str) -> Option<String> {
495 None
496 }
497
498 fn surface_with_one_signal() -> (DecisionSurface, String) {
501 let deltas = ReviewDeltas {
502 boundary_introduced: vec!["ui->-db".to_string()],
503 cycle_introduced: Vec::new(),
504 public_api_added: Vec::new(),
505 };
506 let anchors = vec![BoundaryAnchor {
507 zone_pair_key: "ui->-db".to_string(),
508 from_file: "src/ui/page.ts".to_string(),
509 from_zone: "ui".to_string(),
510 to_zone: "db".to_string(),
511 line: 1,
512 }];
513 let routing = RoutingFacts::default();
514 let surface = extract_decision_surface(&DecisionInputs {
515 deltas: &deltas,
516 boundary_anchors: &anchors,
517 coordination: &[],
518 public_api_anchor_line: 0,
519 affected_not_shown: 3,
520 routing: &routing,
521 head_source: &no_source,
522 rename_old_path: &no_source,
523 internal_consumers: &|_: &str| 0u64,
524 cap: 4,
525 });
526 let real_id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
527 (surface, real_id)
528 }
529
530 #[test]
533 fn clean_agent_json_is_accepted_with_zero_unanchored() {
534 let (surface, real_id) = surface_with_one_signal();
535 let hash = "graph:abc123";
536 let agent = AgentWalkthrough {
537 graph_snapshot_hash: hash.to_string(),
538 judgments: vec![AgentJudgment {
539 signal_id: real_id.clone(),
540 change_anchor: String::new(),
541 framing: "Intended coupling, payments boundary widened on purpose.".to_string(),
542 concern: Some("coupling".to_string()),
543 }],
544 };
545 let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
546 assert!(!validation.stale, "matching hash is not stale");
547 assert_eq!(
548 validation.accepted_count, 1,
549 "the anchored judgment accepts"
550 );
551 assert_eq!(validation.rejected_count, 0, "no rejections");
552 assert_eq!(validation.unanchored_count, 0, "zero unanchored findings");
553 assert!(!validation.accepted[0].deterministic);
555 assert_eq!(validation.accepted[0].signal_id, real_id);
556 }
557
558 #[test]
560 fn injected_unanchored_signal_id_is_rejected() {
561 let (surface, real_id) = surface_with_one_signal();
562 let hash = "graph:abc123";
563 let agent = AgentWalkthrough {
564 graph_snapshot_hash: hash.to_string(),
565 judgments: vec![
566 AgentJudgment {
567 signal_id: real_id.clone(),
568 change_anchor: String::new(),
569 framing: "real".to_string(),
570 concern: None,
571 },
572 AgentJudgment {
573 signal_id: "sig:deadbeefdeadbeef".to_string(),
575 change_anchor: String::new(),
576 framing: "hallucinated decision with no graph anchor".to_string(),
577 concern: None,
578 },
579 ],
580 };
581 let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
582 assert_eq!(validation.accepted_count, 1, "only the real one accepts");
583 assert_eq!(validation.rejected_count, 1, "the fabricated one rejects");
584 assert_eq!(validation.rejected[0].signal_id, "sig:deadbeefdeadbeef");
585 assert_eq!(validation.rejected[0].reason, UNANCHORED_REASON);
586 assert!(
588 validation.accepted.iter().all(|j| j.signal_id == real_id),
589 "accepted excludes the unanchored id"
590 );
591 }
592
593 #[test]
595 fn stale_snapshot_hash_refuses_the_whole_payload() {
596 let (surface, real_id) = surface_with_one_signal();
597 let current_hash = "graph:NEW_after_mutation";
598 let agent = AgentWalkthrough {
600 graph_snapshot_hash: "graph:OLD_before_mutation".to_string(),
601 judgments: vec![AgentJudgment {
602 signal_id: real_id,
604 change_anchor: String::new(),
605 framing: "would be valid, but the tree moved".to_string(),
606 concern: None,
607 }],
608 };
609 let validation =
610 validate_walkthrough(&agent, &surface, &FxHashSet::default(), current_hash);
611 assert!(validation.stale, "old hash is stale");
612 assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
613 assert_eq!(validation.rejected_count, 1, "the judgment is refused");
614 assert_eq!(validation.rejected[0].reason, "stale-snapshot");
615 }
616
617 #[test]
618 fn malformed_agent_json_parses_to_a_stale_refusal() {
619 let agent = parse_agent_walkthrough("{not valid json");
620 assert!(agent.graph_snapshot_hash.is_empty());
621 assert!(agent.judgments.is_empty());
622 let (surface, _) = surface_with_one_signal();
623 let validation =
624 validate_walkthrough(&agent, &surface, &FxHashSet::default(), "graph:real");
625 assert!(
626 validation.stale,
627 "empty echoed hash never matches a real hash"
628 );
629 assert_eq!(validation.accepted_count, 0);
630 }
631
632 fn focus_unit(file: &str, total: u32) -> crate::audit_focus::FocusUnit {
633 crate::audit_focus::FocusUnit {
634 file: file.to_string(),
635 score: crate::audit_focus::FocusScore {
636 total,
637 ..Default::default()
638 },
639 label: crate::audit_focus::FocusLabel::ReviewHere,
640 reason: String::new(),
641 confidence: Vec::new(),
642 }
643 }
644
645 #[test]
646 fn direction_spines_on_focus_units_with_expert_overlay() {
647 let focus = FocusMap {
651 review_here: vec![focus_unit("src/b.ts", 5), focus_unit("src/a.ts", 3)],
652 deprioritized: vec![],
653 };
654 let routing = RoutingFacts {
655 units: vec![RoutingUnit {
656 file: "src/b.ts".to_string(),
657 expert: vec!["@team".to_string()],
658 bus_factor_one: false,
659 }],
660 };
661 let mut out_of_diff_by_file = FxHashMap::default();
663 out_of_diff_by_file.insert("src/a.ts".to_string(), vec!["src/consumer.ts".to_string()]);
664 let direction = build_direction(&focus, &out_of_diff_by_file, &routing);
665 assert_eq!(direction.order, vec!["src/a.ts", "src/b.ts"]);
669 assert_eq!(direction.units[0].file, "src/a.ts");
670 assert_eq!(direction.units[0].concern_lens, "contract-break");
671 assert_eq!(direction.units[0].out_of_diff, vec!["src/consumer.ts"]);
672 assert_eq!(direction.units[0].scoring_budget, 3);
673 assert!(direction.units[0].expert.is_empty());
674 assert_eq!(direction.units[1].file, "src/b.ts");
675 assert_eq!(direction.units[1].concern_lens, "orientation");
676 assert_eq!(direction.units[1].scoring_budget, 5);
677 assert_eq!(direction.units[1].expert, vec!["@team".to_string()]);
678 }
679
680 #[test]
681 fn direction_excludes_non_source_units() {
682 let focus = FocusMap {
683 review_here: vec![
684 focus_unit("LICENSE", 1),
685 focus_unit(".gitignore", 1),
686 focus_unit("README.md", 1),
687 focus_unit("src/app.component.ts", 4),
688 ],
689 deprioritized: vec![],
690 };
691 let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
692 assert_eq!(direction.order, vec!["src/app.component.ts"]);
694 assert_eq!(direction.units[0].concern_lens, "orientation");
695 assert_eq!(direction.units[0].scoring_budget, 4);
696 }
697
698 #[test]
699 fn guide_carries_the_snapshot_hash_and_injection_note() {
700 let digest = ReviewBriefOutput {
701 schema_version: ReviewBriefSchemaVersion::default(),
702 version: "test".to_string(),
703 command: "audit-brief".to_string(),
704 triage: crate::audit_brief::DiffTriage {
705 files: 0,
706 hunks: None,
707 net_lines: None,
708 risk_class: crate::audit_brief::RiskClass::Low,
709 review_effort: crate::audit_brief::ReviewEffort::Glance,
710 },
711 graph_facts: crate::audit_brief::GraphFacts {
712 exports_added: 0,
713 api_width_delta: 0,
714 reachable_from: Vec::new(),
715 boundaries_touched: Vec::new(),
716 },
717 partition: crate::audit_brief::PartitionFacts::default(),
718 impact_closure: crate::audit_brief::ImpactClosureFacts::default(),
719 focus: crate::audit_focus::FocusMap::default(),
720 deltas: ReviewDeltas::default(),
721 weakening: Vec::new(),
722 routing: RoutingFacts::default(),
723 decisions: DecisionSurface::default(),
724 };
725 let guide = build_walkthrough_guide(
726 digest,
727 "graph:pinned".to_string(),
728 ReviewDirection::default(),
729 Vec::new(),
730 );
731 assert_eq!(guide.graph_snapshot_hash, "graph:pinned");
732 assert!(guide.injection_note.contains("untrusted"));
733 assert_eq!(guide.command, "review-walkthrough-guide");
734 assert!(guide.agent_schema.anchoring_rule.contains("rejected"));
735 }
736
737 #[test]
740 fn derive_change_anchor_id_is_stable_and_namespaced() {
741 let added = vec!["const x = 1;".to_string(), "return x;".to_string()];
742 let normalized = normalize_added_text(&added);
743 let id = derive_change_anchor_id("src/a.ts", &normalized, 0);
744 assert!(id.starts_with("chg:"), "namespaced under chg:");
745 assert_eq!(id, derive_change_anchor_id("src/a.ts", &normalized, 0));
747 let reflowed = vec![" const x = 1; ".to_string(), "\treturn x;".to_string()];
749 assert_eq!(
750 id,
751 derive_change_anchor_id("src/a.ts", &normalize_added_text(&reflowed), 0)
752 );
753 assert_ne!(
755 id,
756 derive_change_anchor_id(
757 "src/a.ts",
758 &normalize_added_text(&["const y = 2;".to_string()]),
759 0
760 )
761 );
762 assert_ne!(id, derive_change_anchor_id("src/b.ts", &normalized, 0));
764 }
765
766 #[test]
769 fn parse_change_anchors_is_line_shift_stable() {
770 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";
771 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";
772 let a = parse_change_anchors(diff_a);
773 let b = parse_change_anchors(diff_b);
774 assert_eq!(a.len(), 1);
775 assert_eq!(b.len(), 1);
776 assert_eq!(
777 a[0].change_anchor, b[0].change_anchor,
778 "id is line-shift stable"
779 );
780 assert_eq!(a[0].start_line, 11);
781 assert_eq!(b[0].start_line, 41, "start_line tracks the new position");
782 }
783
784 #[test]
787 fn change_anchor_judgment_accepts_and_unknown_rejects() {
788 let (surface, _) = surface_with_one_signal();
789 let hash = "graph:abc123";
790 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";
791 let anchors = parse_change_anchors(diff);
792 let allow = change_anchor_allowlist(&anchors);
793 let real = anchors[0].change_anchor.clone();
794 let agent = AgentWalkthrough {
795 graph_snapshot_hash: hash.to_string(),
796 judgments: vec![
797 AgentJudgment {
798 signal_id: String::new(),
799 change_anchor: real.clone(),
800 framing: "this region trades simplicity for a cache".to_string(),
801 concern: None,
802 },
803 AgentJudgment {
804 signal_id: String::new(),
805 change_anchor: "chg:deadbeefdeadbeef".to_string(),
806 framing: "hallucinated region".to_string(),
807 concern: None,
808 },
809 ],
810 };
811 let validation = validate_walkthrough(&agent, &surface, &allow, hash);
812 assert_eq!(
813 validation.accepted_count, 1,
814 "the real change_anchor accepts"
815 );
816 assert_eq!(validation.accepted[0].anchor_kind, "change");
817 assert_eq!(validation.accepted[0].change_anchor, real);
818 assert!(validation.accepted[0].signal_id.is_empty());
819 assert!(!validation.accepted[0].deterministic);
820 assert_eq!(
821 validation.rejected_count, 1,
822 "the fabricated region rejects"
823 );
824 assert_eq!(validation.rejected[0].reason, UNKNOWN_CHANGE_ANCHOR_REASON);
825 assert_eq!(validation.rejected[0].change_anchor, "chg:deadbeefdeadbeef");
826 }
827
828 #[test]
830 fn stale_snapshot_refuses_change_anchor_judgment() {
831 let (surface, _) = surface_with_one_signal();
832 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";
833 let anchors = parse_change_anchors(diff);
834 let allow = change_anchor_allowlist(&anchors);
835 let agent = AgentWalkthrough {
836 graph_snapshot_hash: "graph:OLD".to_string(),
837 judgments: vec![AgentJudgment {
838 signal_id: String::new(),
839 change_anchor: anchors[0].change_anchor.clone(),
840 framing: "valid region, but the tree moved".to_string(),
841 concern: None,
842 }],
843 };
844 let validation = validate_walkthrough(&agent, &surface, &allow, "graph:NEW");
845 assert!(validation.stale);
846 assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
847 assert_eq!(validation.rejected[0].reason, "stale-snapshot");
848 }
849
850 #[test]
853 fn change_anchor_survives_rename_via_previous_anchor() {
854 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";
855 let anchors = parse_change_anchors(renamed);
856 assert_eq!(anchors.len(), 1);
857 assert_eq!(anchors[0].file, "src/new.ts");
858 let previous = anchors[0]
859 .previous_change_anchor
860 .clone()
861 .expect("rename yields a previous anchor");
862 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";
864 let old_anchors = parse_change_anchors(old_diff);
865 assert_eq!(previous, old_anchors[0].change_anchor);
866 let allow = change_anchor_allowlist(&anchors);
868 assert!(
869 allow.contains(&previous),
870 "pre-rename id is in the allowlist"
871 );
872 assert!(allow.contains(&anchors[0].change_anchor));
873 }
874}