1use rustc_hash::{FxHashMap, FxHashSet};
36use serde::{Deserialize, Serialize};
37use xxhash_rust::xxh3::xxh3_64;
38
39use crate::audit::routing::RoutingFacts;
40use crate::audit_brief::{ReviewBriefOutput, ReviewBriefSchemaVersion, build_brief_output};
41use crate::audit_decision_surface::DecisionSurface;
42use crate::audit_focus::FocusMap;
43use crate::report::ci::diff_filter::parse_new_hunk_start;
44
45const INJECTION_NOTE: &str = "The digest is built from the deterministic module graph only; PR prose is untrusted and never enters the digest. Your free-text framing is fenced as non-deterministic and never gates or auto-posts.";
48
49const UNANCHORED_REASON: &str = "unanchored-signal-id";
52
53const UNKNOWN_CHANGE_ANCHOR_REASON: &str = "unknown-change-anchor";
57
58#[derive(Debug, Clone, Serialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[allow(
67 clippy::struct_field_names,
68 reason = "change_anchor / previous_change_anchor are the load-bearing wire keys an agent cites; renaming them off the struct name would break the contract"
69)]
70pub struct ChangeAnchor {
71 pub change_anchor: String,
75 pub file: String,
77 pub start_line: u32,
80 pub line_count: u32,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub previous_change_anchor: Option<String>,
87}
88
89fn normalize_added_text(lines: &[String]) -> String {
92 lines
93 .iter()
94 .map(|l| l.trim())
95 .collect::<Vec<_>>()
96 .join("\n")
97}
98
99#[must_use]
105pub fn derive_change_anchor_id(path: &str, normalized_added_text: &str, ordinal: u32) -> String {
106 let mut bytes =
107 Vec::with_capacity(path.len() + 1 + normalized_added_text.len() + 1 + size_of::<u32>());
108 bytes.extend_from_slice(path.as_bytes());
109 bytes.push(0);
110 bytes.extend_from_slice(normalized_added_text.as_bytes());
111 bytes.push(0);
112 bytes.extend_from_slice(&ordinal.to_le_bytes());
113 format!("chg:{:016x}", xxh3_64(&bytes))
114}
115
116#[derive(Default)]
120struct AnchorParser {
121 anchors: Vec<ChangeAnchor>,
122 seen: FxHashMap<(String, String), u32>,
124 current_file: Option<String>,
125 rename_from: Option<String>,
126 pending_rename_from: Option<String>,
127 start_line: u64,
128 hunk_lines: Vec<String>,
129 in_hunk: bool,
130}
131
132impl AnchorParser {
133 fn flush(&mut self) {
138 if let Some(file) = self.current_file.clone()
139 && !self.hunk_lines.is_empty()
140 {
141 let normalized = normalize_added_text(&self.hunk_lines);
142 let counter = self
143 .seen
144 .entry((file.clone(), normalized.clone()))
145 .or_insert(0);
146 let ordinal = *counter;
147 *counter += 1;
148 let change_anchor = derive_change_anchor_id(&file, &normalized, ordinal);
149 let previous_change_anchor = self
150 .rename_from
151 .as_deref()
152 .map(|old| derive_change_anchor_id(old, &normalized, ordinal));
153 self.anchors.push(ChangeAnchor {
154 change_anchor,
155 file,
156 start_line: u32::try_from(self.start_line).unwrap_or(u32::MAX),
157 line_count: u32::try_from(self.hunk_lines.len()).unwrap_or(u32::MAX),
158 previous_change_anchor,
159 });
160 }
161 self.hunk_lines.clear();
162 }
163
164 fn consume(&mut self, line: &str) {
168 if line.starts_with("diff --git ") {
169 self.flush();
170 self.in_hunk = false;
171 self.current_file = None;
172 self.rename_from = None;
173 self.pending_rename_from = None;
174 return;
175 }
176 if let Some(rest) = line.strip_prefix("rename from ") {
177 self.pending_rename_from = Some(rest.to_owned());
178 return;
179 }
180 if let Some(rest) = line.strip_prefix("rename to ") {
181 if let Some(from) = self.pending_rename_from.take() {
182 self.current_file = Some(rest.to_owned());
183 self.rename_from = Some(from);
184 }
185 return;
186 }
187 if let Some(path) = line.strip_prefix("+++ b/") {
188 self.flush();
189 self.in_hunk = false;
190 self.current_file = Some(path.to_owned());
191 return;
192 }
193 if line.starts_with("+++ /dev/null") {
194 self.flush();
195 self.in_hunk = false;
196 self.current_file = None;
197 return;
198 }
199 if let Some(header) = line.strip_prefix("@@ ") {
200 self.flush();
201 self.start_line = parse_new_hunk_start(header).unwrap_or(0);
202 self.in_hunk = true;
203 return;
204 }
205 if self.in_hunk
206 && self.current_file.is_some()
207 && line.starts_with('+')
208 && !line.starts_with("+++")
209 {
210 self.hunk_lines.push(line[1..].to_owned());
211 }
212 }
213}
214
215#[must_use]
220pub fn parse_change_anchors(diff: &str) -> Vec<ChangeAnchor> {
221 let mut parser = AnchorParser::default();
222 for line in diff.lines() {
223 parser.consume(line);
224 }
225 parser.flush();
226 parser.anchors
227}
228
229#[must_use]
233pub fn change_anchor_allowlist(anchors: &[ChangeAnchor]) -> FxHashSet<String> {
234 let mut set = FxHashSet::default();
235 for anchor in anchors {
236 set.insert(anchor.change_anchor.clone());
237 if let Some(previous) = &anchor.previous_change_anchor {
238 set.insert(previous.clone());
239 }
240 }
241 set
242}
243
244#[derive(Debug, Clone, Serialize)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249pub struct DirectionUnit {
250 pub file: String,
252 pub concern_lens: String,
255 pub scoring_budget: u32,
259 pub out_of_diff: Vec<String>,
262 pub expert: Vec<String>,
264}
265
266#[derive(Debug, Clone, Default, Serialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272pub struct ReviewDirection {
273 pub order: Vec<String>,
277 pub units: Vec<DirectionUnit>,
279}
280
281#[derive(Debug, Clone, Serialize)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285pub struct AgentSchema {
286 pub judgment_shape: &'static str,
289 pub echo_field: &'static str,
292 pub anchoring_rule: &'static str,
294}
295
296fn agent_schema() -> AgentSchema {
298 AgentSchema {
299 judgment_shape: "Return { \"graph_snapshot_hash\": <echoed>, \"judgments\": [ { \"signal_id\": <one fallow emitted, OR omit and use change_anchor>, \"change_anchor\": <one fallow emitted chg: id, for a changed region with no finding>, \"framing\": <free text>, \"concern\": <optional> } ] }.",
300 echo_field: "graph_snapshot_hash",
301 anchoring_rule: "Every judgment must cite an emitted signal_id OR an emitted change_anchor; an unanchored id is rejected (anti-hallucination). A change_anchor proves only that the region changed (anchor_kind=change), a weaker guarantee than a signal_id finding (anchor_kind=signal).",
302 }
303}
304
305#[derive(Debug, Clone, Serialize)]
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310#[cfg_attr(
311 feature = "schema",
312 schemars(title = "fallow review --walkthrough-guide --format json")
313)]
314pub struct WalkthroughGuide {
315 pub schema_version: ReviewBriefSchemaVersion,
318 pub version: String,
320 pub command: String,
322 pub graph_snapshot_hash: String,
325 pub digest: ReviewBriefOutput,
327 pub direction: ReviewDirection,
329 pub change_anchors: Vec<ChangeAnchor>,
334 pub agent_schema: AgentSchema,
336 pub injection_note: &'static str,
338}
339
340#[derive(Debug, Clone, Deserialize)]
343pub struct AgentWalkthrough {
344 #[serde(default)]
347 pub graph_snapshot_hash: String,
348 #[serde(default)]
350 pub judgments: Vec<AgentJudgment>,
351}
352
353#[derive(Debug, Clone, Deserialize)]
356pub struct AgentJudgment {
357 #[serde(default)]
361 pub signal_id: String,
362 #[serde(default)]
366 pub change_anchor: String,
367 #[serde(default)]
370 pub framing: String,
371 #[serde(default)]
373 pub concern: Option<String>,
374}
375
376#[derive(Debug, Clone, Serialize)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380pub struct AcceptedJudgment {
381 pub signal_id: String,
384 pub change_anchor: String,
387 pub anchor_kind: String,
392 pub agent_framing: String,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub concern: Option<String>,
397 pub deterministic: bool,
400}
401
402#[derive(Debug, Clone, Serialize)]
404#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
405pub struct RejectedJudgment {
406 pub signal_id: String,
409 pub change_anchor: String,
412 pub reason: String,
416}
417
418#[derive(Debug, Clone, Serialize)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422#[cfg_attr(
423 feature = "schema",
424 schemars(title = "fallow review --walkthrough-file --format json")
425)]
426pub struct WalkthroughValidation {
427 pub schema_version: ReviewBriefSchemaVersion,
429 pub version: String,
431 pub command: String,
433 pub graph_snapshot_hash: String,
435 pub stale: bool,
438 pub accepted: Vec<AcceptedJudgment>,
440 pub rejected: Vec<RejectedJudgment>,
442 pub accepted_count: usize,
444 pub rejected_count: usize,
446 pub unanchored_count: usize,
450}
451
452fn is_reviewable_source_unit(file: &str) -> bool {
457 matches!(
458 std::path::Path::new(file)
459 .extension()
460 .and_then(|e| e.to_str()),
461 Some(
462 "ts" | "tsx"
463 | "js"
464 | "jsx"
465 | "mjs"
466 | "cjs"
467 | "mts"
468 | "cts"
469 | "gts"
470 | "gjs"
471 | "vue"
472 | "svelte"
473 | "astro"
474 )
475 )
476}
477
478#[allow(
489 clippy::implicit_hasher,
490 reason = "fallow standardizes on FxHashMap; fires on the lib target only, so #[expect] is unfulfilled on the bin"
491)]
492#[must_use]
493pub fn build_direction(
494 focus: &FocusMap,
495 out_of_diff_by_file: &FxHashMap<String, Vec<String>>,
496 routing: &RoutingFacts,
497) -> ReviewDirection {
498 let expert_by_file: FxHashMap<&str, &[String]> = routing
501 .units
502 .iter()
503 .map(|unit| (unit.file.as_str(), unit.expert.as_slice()))
504 .collect();
505
506 let mut units: Vec<DirectionUnit> = focus
507 .review_here
508 .iter()
509 .chain(focus.deprioritized.iter())
510 .filter(|unit| is_reviewable_source_unit(&unit.file))
511 .map(|unit| {
512 let out_of_diff = out_of_diff_by_file
516 .get(&unit.file)
517 .cloned()
518 .unwrap_or_default();
519 let concern_lens = if out_of_diff.is_empty() {
520 "orientation".to_string()
521 } else {
522 "contract-break".to_string()
523 };
524 DirectionUnit {
525 file: unit.file.clone(),
526 concern_lens,
527 scoring_budget: unit.score.total,
528 out_of_diff,
529 expert: expert_by_file
530 .get(unit.file.as_str())
531 .map(|experts| experts.to_vec())
532 .unwrap_or_default(),
533 }
534 })
535 .collect();
536
537 units.sort_by(|a, b| {
540 b.out_of_diff
541 .len()
542 .cmp(&a.out_of_diff.len())
543 .then_with(|| b.scoring_budget.cmp(&a.scoring_budget))
544 .then_with(|| a.file.cmp(&b.file))
545 });
546
547 let order = units.iter().map(|u| u.file.clone()).collect();
548 ReviewDirection { order, units }
549}
550
551#[must_use]
554pub fn build_walkthrough_guide(
555 digest: ReviewBriefOutput,
556 graph_snapshot_hash: String,
557 direction: ReviewDirection,
558 change_anchors: Vec<ChangeAnchor>,
559) -> WalkthroughGuide {
560 WalkthroughGuide {
561 schema_version: ReviewBriefSchemaVersion::default(),
562 version: env!("CARGO_PKG_VERSION").to_string(),
563 command: "review-walkthrough-guide".to_string(),
564 graph_snapshot_hash,
565 digest,
566 direction,
567 change_anchors,
568 agent_schema: agent_schema(),
569 injection_note: INJECTION_NOTE,
570 }
571}
572
573#[must_use]
584#[allow(
585 clippy::implicit_hasher,
586 reason = "fallow standardizes on FxHashSet; the change-anchor allowlist is always built with the fallow hasher"
587)]
588pub fn validate_walkthrough(
589 agent: &AgentWalkthrough,
590 surface: &DecisionSurface,
591 change_anchor_ids: &FxHashSet<String>,
592 current_hash: &str,
593) -> WalkthroughValidation {
594 let stale = agent.graph_snapshot_hash != current_hash;
595
596 let mut accepted: Vec<AcceptedJudgment> = Vec::new();
597 let mut rejected: Vec<RejectedJudgment> = Vec::new();
598
599 if stale {
600 for judgment in &agent.judgments {
603 rejected.push(RejectedJudgment {
604 signal_id: judgment.signal_id.clone(),
605 change_anchor: judgment.change_anchor.clone(),
606 reason: "stale-snapshot".to_string(),
607 });
608 }
609 } else {
610 for judgment in &agent.judgments {
611 if !judgment.signal_id.is_empty() && surface.accept_signal_id(&judgment.signal_id) {
614 accepted.push(AcceptedJudgment {
615 signal_id: judgment.signal_id.clone(),
616 change_anchor: String::new(),
617 anchor_kind: "signal".to_string(),
618 agent_framing: judgment.framing.clone(),
619 concern: judgment.concern.clone(),
620 deterministic: false,
621 });
622 } else if !judgment.change_anchor.is_empty()
623 && change_anchor_ids.contains(&judgment.change_anchor)
624 {
625 accepted.push(AcceptedJudgment {
626 signal_id: String::new(),
627 change_anchor: judgment.change_anchor.clone(),
628 anchor_kind: "change".to_string(),
629 agent_framing: judgment.framing.clone(),
630 concern: judgment.concern.clone(),
631 deterministic: false,
632 });
633 } else {
634 let reason = if judgment.signal_id.is_empty() && !judgment.change_anchor.is_empty()
637 {
638 UNKNOWN_CHANGE_ANCHOR_REASON
639 } else {
640 UNANCHORED_REASON
641 };
642 rejected.push(RejectedJudgment {
643 signal_id: judgment.signal_id.clone(),
644 change_anchor: judgment.change_anchor.clone(),
645 reason: reason.to_string(),
646 });
647 }
648 }
649 }
650
651 let accepted_count = accepted.len();
652 let rejected_count = rejected.len();
653 let unanchored_count = 0;
657
658 WalkthroughValidation {
659 schema_version: ReviewBriefSchemaVersion::default(),
660 version: env!("CARGO_PKG_VERSION").to_string(),
661 command: "review-walkthrough-validation".to_string(),
662 graph_snapshot_hash: current_hash.to_string(),
663 stale,
664 accepted,
665 rejected,
666 accepted_count,
667 rejected_count,
668 unanchored_count,
669 }
670}
671
672#[must_use]
677pub fn parse_agent_walkthrough(contents: &str) -> AgentWalkthrough {
678 serde_json::from_str(contents).unwrap_or_else(|_| AgentWalkthrough {
679 graph_snapshot_hash: String::new(),
680 judgments: Vec::new(),
681 })
682}
683
684#[must_use]
688pub fn build_guide_from_result(result: &crate::audit::AuditResult) -> WalkthroughGuide {
689 let digest = build_brief_output(result);
690 let hash = result.graph_snapshot_hash.clone().unwrap_or_default();
691 let empty_routing = RoutingFacts::default();
692 let routing = result.routing.as_ref().unwrap_or(&empty_routing);
693 let mut out_of_diff_by_file: FxHashMap<String, Vec<String>> = FxHashMap::default();
697 if let Some(closure) = result
698 .check
699 .as_ref()
700 .and_then(|c| c.impact_closure.as_ref())
701 {
702 for gap in &closure.coordination_gap {
703 out_of_diff_by_file
704 .entry(gap.changed_file.clone())
705 .or_default()
706 .push(gap.consumer_file.clone());
707 }
708 for consumers in out_of_diff_by_file.values_mut() {
709 consumers.sort();
710 consumers.dedup();
711 }
712 }
713 let direction = build_direction(&digest.focus, &out_of_diff_by_file, routing);
717 build_walkthrough_guide(digest, hash, direction, result.change_anchors.clone())
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use crate::audit::routing::RoutingUnit;
724 use crate::audit_brief::ReviewDeltas;
725 use crate::audit_decision_surface::{
726 BoundaryAnchor, DecisionCategory, DecisionInputs, derive_signal_id,
727 extract_decision_surface,
728 };
729
730 fn no_source(_: &str) -> Option<String> {
731 None
732 }
733
734 fn surface_with_one_signal() -> (DecisionSurface, String) {
737 let deltas = ReviewDeltas {
738 boundary_introduced: vec!["ui->-db".to_string()],
739 cycle_introduced: Vec::new(),
740 public_api_added: Vec::new(),
741 };
742 let anchors = vec![BoundaryAnchor {
743 zone_pair_key: "ui->-db".to_string(),
744 from_file: "src/ui/page.ts".to_string(),
745 from_zone: "ui".to_string(),
746 to_zone: "db".to_string(),
747 line: 1,
748 }];
749 let routing = RoutingFacts::default();
750 let surface = extract_decision_surface(&DecisionInputs {
751 deltas: &deltas,
752 boundary_anchors: &anchors,
753 coordination: &[],
754 public_api_anchor_line: 0,
755 affected_not_shown: 3,
756 routing: &routing,
757 head_source: &no_source,
758 rename_old_path: &no_source,
759 internal_consumers: &|_: &str| 0u64,
760 cap: 4,
761 });
762 let real_id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
763 (surface, real_id)
764 }
765
766 #[test]
769 fn clean_agent_json_is_accepted_with_zero_unanchored() {
770 let (surface, real_id) = surface_with_one_signal();
771 let hash = "graph:abc123";
772 let agent = AgentWalkthrough {
773 graph_snapshot_hash: hash.to_string(),
774 judgments: vec![AgentJudgment {
775 signal_id: real_id.clone(),
776 change_anchor: String::new(),
777 framing: "Intended coupling, payments boundary widened on purpose.".to_string(),
778 concern: Some("coupling".to_string()),
779 }],
780 };
781 let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
782 assert!(!validation.stale, "matching hash is not stale");
783 assert_eq!(
784 validation.accepted_count, 1,
785 "the anchored judgment accepts"
786 );
787 assert_eq!(validation.rejected_count, 0, "no rejections");
788 assert_eq!(validation.unanchored_count, 0, "zero unanchored findings");
789 assert!(!validation.accepted[0].deterministic);
791 assert_eq!(validation.accepted[0].signal_id, real_id);
792 }
793
794 #[test]
796 fn injected_unanchored_signal_id_is_rejected() {
797 let (surface, real_id) = surface_with_one_signal();
798 let hash = "graph:abc123";
799 let agent = AgentWalkthrough {
800 graph_snapshot_hash: hash.to_string(),
801 judgments: vec![
802 AgentJudgment {
803 signal_id: real_id.clone(),
804 change_anchor: String::new(),
805 framing: "real".to_string(),
806 concern: None,
807 },
808 AgentJudgment {
809 signal_id: "sig:deadbeefdeadbeef".to_string(),
811 change_anchor: String::new(),
812 framing: "hallucinated decision with no graph anchor".to_string(),
813 concern: None,
814 },
815 ],
816 };
817 let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
818 assert_eq!(validation.accepted_count, 1, "only the real one accepts");
819 assert_eq!(validation.rejected_count, 1, "the fabricated one rejects");
820 assert_eq!(validation.rejected[0].signal_id, "sig:deadbeefdeadbeef");
821 assert_eq!(validation.rejected[0].reason, UNANCHORED_REASON);
822 assert!(
824 validation.accepted.iter().all(|j| j.signal_id == real_id),
825 "accepted excludes the unanchored id"
826 );
827 }
828
829 #[test]
831 fn stale_snapshot_hash_refuses_the_whole_payload() {
832 let (surface, real_id) = surface_with_one_signal();
833 let current_hash = "graph:NEW_after_mutation";
834 let agent = AgentWalkthrough {
836 graph_snapshot_hash: "graph:OLD_before_mutation".to_string(),
837 judgments: vec![AgentJudgment {
838 signal_id: real_id,
840 change_anchor: String::new(),
841 framing: "would be valid, but the tree moved".to_string(),
842 concern: None,
843 }],
844 };
845 let validation =
846 validate_walkthrough(&agent, &surface, &FxHashSet::default(), current_hash);
847 assert!(validation.stale, "old hash is stale");
848 assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
849 assert_eq!(validation.rejected_count, 1, "the judgment is refused");
850 assert_eq!(validation.rejected[0].reason, "stale-snapshot");
851 }
852
853 #[test]
854 fn malformed_agent_json_parses_to_a_stale_refusal() {
855 let agent = parse_agent_walkthrough("{not valid json");
856 assert!(agent.graph_snapshot_hash.is_empty());
857 assert!(agent.judgments.is_empty());
858 let (surface, _) = surface_with_one_signal();
859 let validation =
860 validate_walkthrough(&agent, &surface, &FxHashSet::default(), "graph:real");
861 assert!(
862 validation.stale,
863 "empty echoed hash never matches a real hash"
864 );
865 assert_eq!(validation.accepted_count, 0);
866 }
867
868 fn focus_unit(file: &str, total: u32) -> crate::audit_focus::FocusUnit {
869 crate::audit_focus::FocusUnit {
870 file: file.to_string(),
871 score: crate::audit_focus::FocusScore {
872 total,
873 ..Default::default()
874 },
875 label: crate::audit_focus::FocusLabel::ReviewHere,
876 reason: String::new(),
877 confidence: Vec::new(),
878 }
879 }
880
881 #[test]
882 fn direction_spines_on_focus_units_with_expert_overlay() {
883 let focus = FocusMap {
887 review_here: vec![focus_unit("src/b.ts", 5), focus_unit("src/a.ts", 3)],
888 deprioritized: vec![],
889 };
890 let routing = RoutingFacts {
891 units: vec![RoutingUnit {
892 file: "src/b.ts".to_string(),
893 expert: vec!["@team".to_string()],
894 bus_factor_one: false,
895 }],
896 };
897 let mut out_of_diff_by_file = FxHashMap::default();
899 out_of_diff_by_file.insert("src/a.ts".to_string(), vec!["src/consumer.ts".to_string()]);
900 let direction = build_direction(&focus, &out_of_diff_by_file, &routing);
901 assert_eq!(direction.order, vec!["src/a.ts", "src/b.ts"]);
905 assert_eq!(direction.units[0].file, "src/a.ts");
906 assert_eq!(direction.units[0].concern_lens, "contract-break");
907 assert_eq!(direction.units[0].out_of_diff, vec!["src/consumer.ts"]);
908 assert_eq!(direction.units[0].scoring_budget, 3);
909 assert!(direction.units[0].expert.is_empty());
910 assert_eq!(direction.units[1].file, "src/b.ts");
911 assert_eq!(direction.units[1].concern_lens, "orientation");
912 assert_eq!(direction.units[1].scoring_budget, 5);
913 assert_eq!(direction.units[1].expert, vec!["@team".to_string()]);
914 }
915
916 #[test]
917 fn direction_excludes_non_source_units() {
918 let focus = FocusMap {
919 review_here: vec![
920 focus_unit("LICENSE", 1),
921 focus_unit(".gitignore", 1),
922 focus_unit("README.md", 1),
923 focus_unit("src/app.component.ts", 4),
924 ],
925 deprioritized: vec![],
926 };
927 let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
928 assert_eq!(direction.order, vec!["src/app.component.ts"]);
930 assert_eq!(direction.units[0].concern_lens, "orientation");
931 assert_eq!(direction.units[0].scoring_budget, 4);
932 }
933
934 #[test]
935 fn guide_carries_the_snapshot_hash_and_injection_note() {
936 let digest = ReviewBriefOutput {
937 schema_version: ReviewBriefSchemaVersion::default(),
938 version: "test".to_string(),
939 command: "audit-brief".to_string(),
940 triage: crate::audit_brief::DiffTriage {
941 files: 0,
942 hunks: None,
943 net_lines: None,
944 risk_class: crate::audit_brief::RiskClass::Low,
945 review_effort: crate::audit_brief::ReviewEffort::Glance,
946 },
947 graph_facts: crate::audit_brief::GraphFacts {
948 exports_added: 0,
949 api_width_delta: 0,
950 reachable_from: Vec::new(),
951 boundaries_touched: Vec::new(),
952 },
953 partition: crate::audit_brief::PartitionFacts::default(),
954 impact_closure: crate::audit_brief::ImpactClosureFacts::default(),
955 focus: crate::audit_focus::FocusMap::default(),
956 deltas: ReviewDeltas::default(),
957 weakening: Vec::new(),
958 routing: RoutingFacts::default(),
959 decisions: DecisionSurface::default(),
960 };
961 let guide = build_walkthrough_guide(
962 digest,
963 "graph:pinned".to_string(),
964 ReviewDirection::default(),
965 Vec::new(),
966 );
967 assert_eq!(guide.graph_snapshot_hash, "graph:pinned");
968 assert!(guide.injection_note.contains("untrusted"));
969 assert_eq!(guide.command, "review-walkthrough-guide");
970 assert!(guide.agent_schema.anchoring_rule.contains("rejected"));
971 }
972
973 #[test]
976 fn derive_change_anchor_id_is_stable_and_namespaced() {
977 let added = vec!["const x = 1;".to_string(), "return x;".to_string()];
978 let normalized = normalize_added_text(&added);
979 let id = derive_change_anchor_id("src/a.ts", &normalized, 0);
980 assert!(id.starts_with("chg:"), "namespaced under chg:");
981 assert_eq!(id, derive_change_anchor_id("src/a.ts", &normalized, 0));
983 let reflowed = vec![" const x = 1; ".to_string(), "\treturn x;".to_string()];
985 assert_eq!(
986 id,
987 derive_change_anchor_id("src/a.ts", &normalize_added_text(&reflowed), 0)
988 );
989 assert_ne!(
991 id,
992 derive_change_anchor_id(
993 "src/a.ts",
994 &normalize_added_text(&["const y = 2;".to_string()]),
995 0
996 )
997 );
998 assert_ne!(id, derive_change_anchor_id("src/b.ts", &normalized, 0));
1000 }
1001
1002 #[test]
1005 fn parse_change_anchors_is_line_shift_stable() {
1006 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";
1007 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";
1008 let a = parse_change_anchors(diff_a);
1009 let b = parse_change_anchors(diff_b);
1010 assert_eq!(a.len(), 1);
1011 assert_eq!(b.len(), 1);
1012 assert_eq!(
1013 a[0].change_anchor, b[0].change_anchor,
1014 "id is line-shift stable"
1015 );
1016 assert_eq!(a[0].start_line, 11);
1017 assert_eq!(b[0].start_line, 41, "start_line tracks the new position");
1018 }
1019
1020 #[test]
1023 fn change_anchor_judgment_accepts_and_unknown_rejects() {
1024 let (surface, _) = surface_with_one_signal();
1025 let hash = "graph:abc123";
1026 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";
1027 let anchors = parse_change_anchors(diff);
1028 let allow = change_anchor_allowlist(&anchors);
1029 let real = anchors[0].change_anchor.clone();
1030 let agent = AgentWalkthrough {
1031 graph_snapshot_hash: hash.to_string(),
1032 judgments: vec![
1033 AgentJudgment {
1034 signal_id: String::new(),
1035 change_anchor: real.clone(),
1036 framing: "this region trades simplicity for a cache".to_string(),
1037 concern: None,
1038 },
1039 AgentJudgment {
1040 signal_id: String::new(),
1041 change_anchor: "chg:deadbeefdeadbeef".to_string(),
1042 framing: "hallucinated region".to_string(),
1043 concern: None,
1044 },
1045 ],
1046 };
1047 let validation = validate_walkthrough(&agent, &surface, &allow, hash);
1048 assert_eq!(
1049 validation.accepted_count, 1,
1050 "the real change_anchor accepts"
1051 );
1052 assert_eq!(validation.accepted[0].anchor_kind, "change");
1053 assert_eq!(validation.accepted[0].change_anchor, real);
1054 assert!(validation.accepted[0].signal_id.is_empty());
1055 assert!(!validation.accepted[0].deterministic);
1056 assert_eq!(
1057 validation.rejected_count, 1,
1058 "the fabricated region rejects"
1059 );
1060 assert_eq!(validation.rejected[0].reason, UNKNOWN_CHANGE_ANCHOR_REASON);
1061 assert_eq!(validation.rejected[0].change_anchor, "chg:deadbeefdeadbeef");
1062 }
1063
1064 #[test]
1066 fn stale_snapshot_refuses_change_anchor_judgment() {
1067 let (surface, _) = surface_with_one_signal();
1068 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";
1069 let anchors = parse_change_anchors(diff);
1070 let allow = change_anchor_allowlist(&anchors);
1071 let agent = AgentWalkthrough {
1072 graph_snapshot_hash: "graph:OLD".to_string(),
1073 judgments: vec![AgentJudgment {
1074 signal_id: String::new(),
1075 change_anchor: anchors[0].change_anchor.clone(),
1076 framing: "valid region, but the tree moved".to_string(),
1077 concern: None,
1078 }],
1079 };
1080 let validation = validate_walkthrough(&agent, &surface, &allow, "graph:NEW");
1081 assert!(validation.stale);
1082 assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
1083 assert_eq!(validation.rejected[0].reason, "stale-snapshot");
1084 }
1085
1086 #[test]
1089 fn change_anchor_survives_rename_via_previous_anchor() {
1090 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";
1091 let anchors = parse_change_anchors(renamed);
1092 assert_eq!(anchors.len(), 1);
1093 assert_eq!(anchors[0].file, "src/new.ts");
1094 let previous = anchors[0]
1095 .previous_change_anchor
1096 .clone()
1097 .expect("rename yields a previous anchor");
1098 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";
1100 let old_anchors = parse_change_anchors(old_diff);
1101 assert_eq!(previous, old_anchors[0].change_anchor);
1102 let allow = change_anchor_allowlist(&anchors);
1104 assert!(
1105 allow.contains(&previous),
1106 "pre-rename id is in the allowlist"
1107 );
1108 assert!(allow.contains(&anchors[0].change_anchor));
1109 }
1110}