Skip to main content

fallow_cli/
audit_walkthrough.rs

1//! Agent-contract loop (the codiff pattern, graph-extended).
2//!
3//! Closes the steer-the-agent loop. The tool owns the digest + prompt + schema;
4//! the agent owns judgment; fallow post-validates the agent's judgment against
5//! the LIVE graph + diff. The reentry is the `--walkthrough-file` path, exactly
6//! as codiff re-resolves hunk ids against the live diff at validation time, but
7//! fallow's verifier is the deterministic module graph, not a second model.
8//!
9//! ## LEAD PRINCIPLE: the verifier is the graph, not a second model
10//!
11//! Trust comes from deterministic, reproducible, graph-adjudicated post-validation
12//! that cannot hallucinate. Two mechanisms enforce it:
13//!
14//! 1. **Anti-hallucination** (anchoring): every agent judgment MUST cite a
15//!    `signal_id` fallow emitted.
16//!    [`DecisionSurface::accept_signal_id`](crate::audit_decision_surface::DecisionSurface::accept_signal_id)
17//!    is the allowlist; a judgment whose id was never emitted is REJECTED. The
18//!    agent proposes; the graph disposes.
19//! 2. **Staleness refusal** (snapshot pin): the digest (the `WalkthroughGuide`)
20//!    carries a deterministic `graph_snapshot_hash`, a stable hash of the
21//!    relevant graph + diff state. The agent echoes it back in its JSON; if the
22//!    tree moved between the guide emission and the reentry, the current hash
23//!    differs and the WHOLE payload is REFUSED as stale.
24//!
25//! ## Injection-resistance by construction
26//!
27//! The digest is built ONLY from the deterministic graph
28//! ([`crate::audit_brief::build_brief_output`], pure over the tree). PR prose
29//! NEVER enters the digest. On reentry, the agent's free-text framing is FENCED
30//! (marked non-deterministic) onto the validation output; it never gates, never
31//! auto-posts, and never folds back into the digest. Treat any PR prose fed to an
32//! agent as untrusted: this loop is injection-resistant because the trusted
33//! surface is the graph, and the untrusted surface is fenced.
34
35pub 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
50/// The standing reason a judgment is rejected for citing a `signal_id` fallow
51/// never emitted (the anti-hallucination gate).
52const UNANCHORED_REASON: &str = "unanchored-signal-id";
53
54/// The reason a judgment is rejected for citing a `change_anchor` (a `chg:` id)
55/// that fallow did not emit for this changed set (the anti-hallucination gate
56/// for the weaker, region-level anchor).
57const UNKNOWN_CHANGE_ANCHOR_REASON: &str = "unknown-change-anchor";
58
59pub type WalkthroughGuide = fallow_output::StandardWalkthroughGuide;
60
61/// Strip per-line leading/trailing whitespace and join added lines with `\n`, so
62/// a reflow or a whitespace-only edit does not move the content-addressed id.
63fn normalize_added_text(lines: &[String]) -> String {
64    lines
65        .iter()
66        .map(|l| l.trim())
67        .collect::<Vec<_>>()
68        .join("\n")
69}
70
71/// Derive a stable, CONTENT-addressed change-anchor id. Hashes ONLY the file
72/// path + the normalized added text + an occurrence ordinal (to disambiguate
73/// byte-identical hunks in one file). Line numbers are deliberately excluded so
74/// the id survives edits above the hunk and whitespace-only changes. Mirrors
75/// [`crate::audit_decision_surface::derive_signal_id`] with a `chg:` namespace.
76#[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/// Mutable accumulator threaded through [`parse_change_anchors`] while walking a
89/// unified diff. Holds the current file, rename provenance, and the in-progress
90/// hunk; [`AnchorParser::flush`] turns the accumulated hunk into one anchor.
91#[derive(Default)]
92struct AnchorParser {
93    anchors: Vec<ChangeAnchor>,
94    /// `(file, normalized text) -> next ordinal` for byte-identical hunks.
95    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    /// Flush the accumulated hunk into one anchor (computing its occurrence
106    /// ordinal for byte-identical normalized text within the same file), then
107    /// clear the hunk buffer. No-op when there is no current file or no added
108    /// lines.
109    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    /// Consume one diff line, flushing any pending hunk on a structural boundary
137    /// (`diff --git`, `+++ b/`, `+++ /dev/null`, `@@`) and accumulating `+` lines
138    /// inside a hunk.
139    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/// Parse a zero-context unified diff (`git diff --unified=0`) into per-hunk
188/// [`ChangeAnchor`]s. Each hunk's added (`+`) lines form one anchor. Rename
189/// headers make the anchor rename-durable via `previous_change_anchor`. Pure:
190/// the same diff text always yields the same anchors.
191#[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/// Build the change-anchor allowlist from the emitted anchors: every current id
202/// plus every `previous_change_anchor` (so a judgment that cited an anchor under
203/// a pre-rename path still resolves).
204#[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
216/// True when a routing unit names an analyzable source file worth steering a
217/// reviewer through. Non-code churn (LICENSE, .gitignore, README.md, JSON/YAML
218/// config, lockfiles) is excluded from the direction: it carries no contract to
219/// break and only dilutes the order the agent executes.
220fn 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/// Build the review direction. The SPINE is the change itself: every reviewable
243/// focus unit (`review_here` + the `deprioritized` escape hatch), so the
244/// direction is never empty when there is code to review. Ownership routing is a
245/// LEFT-JOINED overlay for the optional `expert` field, NOT the spine: sourcing
246/// the work-list from routing made it empty on solo / author's-own-PR changes (no
247/// one else to ask), which is exactly the cloud's dominant case. Each unit carries
248/// its `scoring_budget` (the focus composite score) so a fan-out spends AI
249/// proportional to risk, its per-file `out_of_diff` consumers, and the
250/// `concern_lens`. Non-source churn is excluded. Units with out-of-diff consumers
251/// sort first (load-bearing definitions before mechanical churn), then by budget.
252#[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    // Optional expert overlay: file -> routed expert(s). Empty on the author's own
263    // PR, which is why it is an overlay and not the spine.
264    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            // Per-unit out-of-diff: the consumers of THIS file outside the diff. A
277            // unit that breaks a contract gets the contract-break lens; the rest
278            // the plain orientation lens. Graph-derived.
279            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    // Review the load-bearing units first: contract-breakers (out-of-diff
302    // consumers) ahead of the rest, then by budget (riskiest first), then path.
303    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/// Assemble the walkthrough guide from the assembled brief data. Pure over its
316/// inputs: the same digest + hash always produce the same guide.
317#[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/// Post-validate the agent's judgment JSON against the live graph.
338///
339/// The graph is the verifier:
340/// 1. If the agent's echoed `graph_snapshot_hash` != `current_hash`, the tree
341///    moved: REFUSE the whole payload as stale (accepted empty, every judgment
342///    rejected with `stale-snapshot`).
343/// 2. Otherwise, each judgment is ACCEPTED iff its `signal_id` is on the
344///    decision surface's emitted allowlist ([`DecisionSurface::accept_signal_id`]);
345///    an unanchored id is REJECTED (`unanchored-signal-id`). Accepted judgments
346///    carry the agent's framing FENCED as non-deterministic.
347#[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        // Staleness refusal: the tree moved, so NOTHING the agent said can be
365        // trusted against this graph. Refuse the whole payload.
366        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            // A signal_id (graph finding) is the strong anchor; a change_anchor
376            // (changed region) is the weaker fallback. Prefer the signal.
377            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                // Cited a change_anchor (but no valid signal_id) and it did not
399                // resolve -> the region-level miss; otherwise the signal-id miss.
400                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    // Every accepted judgment is anchored by construction (accept_signal_id was
418    // true), so the unanchored count among accepted is always zero. Surfaced as
419    // an explicit field so the done-condition asserts on it directly.
420    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/// Parse the agent's judgment JSON from a `--walkthrough-file` path's contents.
437/// A malformed payload yields an empty `AgentWalkthrough` whose default hash
438/// (`""`) will not match any real snapshot hash, so it is refused as stale (the
439/// safe direction: a garbled agent file never accepts).
440#[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/// Assemble the walkthrough guide from an [`crate::audit::AuditResult`] on the
449/// brief path. Reuses [`build_brief_output`] for the digest (graph-only, pure)
450/// and the retained routing + impact closure for the direction.
451#[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    // Per-file out-of-diff map from the (post-stories-filter) coordination gaps:
458    // each changed file -> the consumers outside the diff it actually affects, so
459    // every direction unit carries its OWN out-of-diff, not the global set.
460    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    // Spine the direction on the CHANGE (the focus units), with routing as the
478    // optional expert overlay, so the work-list is never empty on the author's own
479    // PR. Borrow `digest.focus` before `digest` is moved into the guide.
480    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    /// Build a synthetic decision surface with one coupling/boundary decision,
499    /// returning the surface plus the one real emitted signal id.
500    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    // Done-condition (a): a valid agent JSON citing only emitted signal_ids with
531    // the correct snapshot hash is ACCEPTED with zero unanchored findings.
532    #[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        // The framing is fenced as non-deterministic.
554        assert!(!validation.accepted[0].deterministic);
555        assert_eq!(validation.accepted[0].signal_id, real_id);
556    }
557
558    // Done-condition (b): an injected unanchored finding is REJECTED.
559    #[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                    // A fabricated id fallow never emitted.
574                    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        // The accepted set never contains the fabricated id.
587        assert!(
588            validation.accepted.iter().all(|j| j.signal_id == real_id),
589            "accepted excludes the unanchored id"
590        );
591    }
592
593    // Done-condition (c): stale JSON (mutated tree / old snapshot hash) is REFUSED.
594    #[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        // The agent echoed the OLD hash (the tree moved since the guide).
599        let agent = AgentWalkthrough {
600            graph_snapshot_hash: "graph:OLD_before_mutation".to_string(),
601            judgments: vec![AgentJudgment {
602                // Even a real signal id is refused under a stale snapshot.
603                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        // The SPINE is the change (focus units), never the routing. The author's
648        // own PR has expert: [] on every routing unit, yet the direction still
649        // enumerates the units. b.ts has a real expert overlay; a.ts has none.
650        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        // Only src/a.ts has an out-of-diff consumer; src/b.ts has none.
662        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        // a.ts breaks a contract -> sorts first with the contract-break lens,
666        // carrying its budget; b.ts has no out-of-diff -> orientation, but the
667        // expert overlay still attaches @team.
668        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        // Only the source unit survives; docs/config/license churn is dropped.
693        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    // change_anchor: a content-addressed id is stable across line-shifts and
738    // whitespace-only edits, and namespaced under `chg:`.
739    #[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        // Same content at a DIFFERENT line (the start line is not hashed) -> same id.
746        assert_eq!(id, derive_change_anchor_id("src/a.ts", &normalized, 0));
747        // A whitespace-only reflow normalizes to the same text -> same id.
748        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        // Different added text -> different id.
754        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        // Same text in a different file -> different id.
763        assert_ne!(id, derive_change_anchor_id("src/b.ts", &normalized, 0));
764    }
765
766    // change_anchor: parsing a unified diff yields one anchor per hunk; an edit
767    // ABOVE a hunk shifts its start_line but NOT its content-addressed id.
768    #[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    // change_anchor: a judgment citing an emitted change_anchor is ACCEPTED with
785    // anchor_kind=change; an unknown change_anchor is REJECTED.
786    #[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    // change_anchor: a stale snapshot refuses a change_anchor judgment too.
829    #[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    // change_anchor: a renamed file's anchor resolves via previous_change_anchor,
851    // so an agent that cited the pre-rename id still anchors.
852    #[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        // The previous id equals what the same hunk under the OLD path would emit.
863        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        // The allowlist contains BOTH the new id and the pre-rename id.
867        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}