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, AgentJudgment, AgentWalkthrough, ChangeAnchor, DirectionUnit, INJECTION_NOTE,
37    RejectedJudgment, ReviewDirection, WalkthroughValidation, agent_schema,
38};
39use fallow_output::{FocusMap, RoutingFacts};
40use rustc_hash::{FxHashMap, FxHashSet};
41use xxhash_rust::xxh3::xxh3_64;
42
43use crate::audit_brief::{ReviewBriefOutput, ReviewBriefSchemaVersion, build_brief_output};
44use crate::audit_decision_surface::DecisionSurface;
45use crate::report::ci::diff_filter::parse_new_hunk_start;
46
47/// The standing reason a judgment is rejected for citing a `signal_id` fallow
48/// never emitted (the anti-hallucination gate).
49const UNANCHORED_REASON: &str = "unanchored-signal-id";
50
51/// The reason a judgment is rejected for citing a `change_anchor` (a `chg:` id)
52/// that fallow did not emit for this changed set (the anti-hallucination gate
53/// for the weaker, region-level anchor).
54const UNKNOWN_CHANGE_ANCHOR_REASON: &str = "unknown-change-anchor";
55
56pub type WalkthroughGuide = fallow_output::StandardWalkthroughGuide;
57
58/// Strip per-line leading/trailing whitespace and join added lines with `\n`, so
59/// a reflow or a whitespace-only edit does not move the content-addressed id.
60fn normalize_added_text(lines: &[String]) -> String {
61    lines
62        .iter()
63        .map(|l| l.trim())
64        .collect::<Vec<_>>()
65        .join("\n")
66}
67
68/// Derive a stable, CONTENT-addressed change-anchor id. Hashes ONLY the file
69/// path + the normalized added text + an occurrence ordinal (to disambiguate
70/// byte-identical hunks in one file). Line numbers are deliberately excluded so
71/// the id survives edits above the hunk and whitespace-only changes. Mirrors
72/// [`crate::audit_decision_surface::derive_signal_id`] with a `chg:` namespace.
73#[must_use]
74pub fn derive_change_anchor_id(path: &str, normalized_added_text: &str, ordinal: u32) -> String {
75    let mut bytes =
76        Vec::with_capacity(path.len() + 1 + normalized_added_text.len() + 1 + size_of::<u32>());
77    bytes.extend_from_slice(path.as_bytes());
78    bytes.push(0);
79    bytes.extend_from_slice(normalized_added_text.as_bytes());
80    bytes.push(0);
81    bytes.extend_from_slice(&ordinal.to_le_bytes());
82    format!("chg:{:016x}", xxh3_64(&bytes))
83}
84
85/// Mutable accumulator threaded through [`parse_change_anchors`] while walking a
86/// unified diff. Holds the current file, rename provenance, and the in-progress
87/// hunk; [`AnchorParser::flush`] turns the accumulated hunk into one anchor.
88#[derive(Default)]
89struct AnchorParser {
90    anchors: Vec<ChangeAnchor>,
91    /// `(file, normalized text) -> next ordinal` for byte-identical hunks.
92    seen: FxHashMap<(String, String), u32>,
93    current_file: Option<String>,
94    rename_from: Option<String>,
95    pending_rename_from: Option<String>,
96    start_line: u64,
97    hunk_lines: Vec<String>,
98    in_hunk: bool,
99}
100
101impl AnchorParser {
102    /// Flush the accumulated hunk into one anchor (computing its occurrence
103    /// ordinal for byte-identical normalized text within the same file), then
104    /// clear the hunk buffer. No-op when there is no current file or no added
105    /// lines.
106    fn flush(&mut self) {
107        if let Some(file) = self.current_file.clone()
108            && !self.hunk_lines.is_empty()
109        {
110            let normalized = normalize_added_text(&self.hunk_lines);
111            let counter = self
112                .seen
113                .entry((file.clone(), normalized.clone()))
114                .or_insert(0);
115            let ordinal = *counter;
116            *counter += 1;
117            let change_anchor = derive_change_anchor_id(&file, &normalized, ordinal);
118            let previous_change_anchor = self
119                .rename_from
120                .as_deref()
121                .map(|old| derive_change_anchor_id(old, &normalized, ordinal));
122            self.anchors.push(ChangeAnchor {
123                change_anchor,
124                file,
125                start_line: u32::try_from(self.start_line).unwrap_or(u32::MAX),
126                line_count: u32::try_from(self.hunk_lines.len()).unwrap_or(u32::MAX),
127                previous_change_anchor,
128            });
129        }
130        self.hunk_lines.clear();
131    }
132
133    /// Consume one diff line, flushing any pending hunk on a structural boundary
134    /// (`diff --git`, `+++ b/`, `+++ /dev/null`, `@@`) and accumulating `+` lines
135    /// inside a hunk.
136    fn consume(&mut self, line: &str) {
137        if line.starts_with("diff --git ") {
138            self.flush();
139            self.in_hunk = false;
140            self.current_file = None;
141            self.rename_from = None;
142            self.pending_rename_from = None;
143            return;
144        }
145        if let Some(rest) = line.strip_prefix("rename from ") {
146            self.pending_rename_from = Some(rest.to_owned());
147            return;
148        }
149        if let Some(rest) = line.strip_prefix("rename to ") {
150            if let Some(from) = self.pending_rename_from.take() {
151                self.current_file = Some(rest.to_owned());
152                self.rename_from = Some(from);
153            }
154            return;
155        }
156        if let Some(path) = line.strip_prefix("+++ b/") {
157            self.flush();
158            self.in_hunk = false;
159            self.current_file = Some(path.to_owned());
160            return;
161        }
162        if line.starts_with("+++ /dev/null") {
163            self.flush();
164            self.in_hunk = false;
165            self.current_file = None;
166            return;
167        }
168        if let Some(header) = line.strip_prefix("@@ ") {
169            self.flush();
170            self.start_line = parse_new_hunk_start(header).unwrap_or(0);
171            self.in_hunk = true;
172            return;
173        }
174        if self.in_hunk
175            && self.current_file.is_some()
176            && line.starts_with('+')
177            && !line.starts_with("+++")
178        {
179            self.hunk_lines.push(line[1..].to_owned());
180        }
181    }
182}
183
184/// Parse a zero-context unified diff (`git diff --unified=0`) into per-hunk
185/// [`ChangeAnchor`]s. Each hunk's added (`+`) lines form one anchor. Rename
186/// headers make the anchor rename-durable via `previous_change_anchor`. Pure:
187/// the same diff text always yields the same anchors.
188#[must_use]
189pub fn parse_change_anchors(diff: &str) -> Vec<ChangeAnchor> {
190    let mut parser = AnchorParser::default();
191    for line in diff.lines() {
192        parser.consume(line);
193    }
194    parser.flush();
195    parser.anchors
196}
197
198/// Build the change-anchor allowlist from the emitted anchors: every current id
199/// plus every `previous_change_anchor` (so a judgment that cited an anchor under
200/// a pre-rename path still resolves).
201#[must_use]
202pub fn change_anchor_allowlist(anchors: &[ChangeAnchor]) -> FxHashSet<String> {
203    let mut set = FxHashSet::default();
204    for anchor in anchors {
205        set.insert(anchor.change_anchor.clone());
206        if let Some(previous) = &anchor.previous_change_anchor {
207            set.insert(previous.clone());
208        }
209    }
210    set
211}
212
213/// True when a routing unit names an analyzable source file worth steering a
214/// reviewer through. Non-code churn (LICENSE, .gitignore, README.md, JSON/YAML
215/// config, lockfiles) is excluded from the direction: it carries no contract to
216/// break and only dilutes the order the agent executes.
217fn is_reviewable_source_unit(file: &str) -> bool {
218    matches!(
219        std::path::Path::new(file)
220            .extension()
221            .and_then(|e| e.to_str()),
222        Some(
223            "ts" | "tsx"
224                | "js"
225                | "jsx"
226                | "mjs"
227                | "cjs"
228                | "mts"
229                | "cts"
230                | "gts"
231                | "gjs"
232                | "vue"
233                | "svelte"
234                | "astro"
235        )
236    )
237}
238
239/// Read the displayed fan-in (importer) and fan-out counts back out of a focus
240/// unit's reason string. The reason is the canonical rendered "why" both surfaces
241/// print verbatim for a Stage-2 row ("high fan-in (N importers), fan-out M, ..."),
242/// built in `audit_focus::build_reason` straight from the raw
243/// `facts.fan_in` / `facts.fan_out`. Sorting on these parsed counts makes the
244/// within-stage order EQUAL the number on the row, instead of the hidden, capped
245/// `fan_io` blend the composite feeds on (which undersells a high-fan-in file
246/// past the cap). Returns `(0, 0)` when neither phrase is present (the reason has
247/// no fan signal), so such a unit sorts last and falls to the path tiebreak.
248fn parse_fan_counts(reason: &str) -> (u32, u32) {
249    let fan_in = extract_count(reason, "high fan-in (");
250    let fan_out = extract_count(reason, "fan-out ");
251    (fan_in, fan_out)
252}
253
254/// Parse the leading run of decimal digits immediately following `marker` in
255/// `text`. Returns `0` when the marker is absent or is not followed by a digit.
256fn extract_count(text: &str, marker: &str) -> u32 {
257    let Some(rest) = text.find(marker).map(|i| &text[i + marker.len()..]) else {
258        return 0;
259    };
260    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
261    digits.parse().unwrap_or(0)
262}
263
264/// Build the review direction. The SPINE is the change itself: every reviewable
265/// focus unit (`review_here` + the `deprioritized` escape hatch), so the
266/// direction is never empty when there is code to review. Ownership routing is a
267/// LEFT-JOINED overlay for the optional `expert` field, NOT the spine: sourcing
268/// the work-list from routing made it empty on solo / author's-own-PR changes (no
269/// one else to ask), which is exactly the cloud's dominant case. Each unit carries
270/// its `scoring_budget` (the focus composite score) so a fan-out spends AI
271/// proportional to risk, its per-file `out_of_diff` consumers, and the
272/// `concern_lens`. Non-source churn is excluded. Units with out-of-diff consumers
273/// sort first (load-bearing definitions before mechanical churn), then by budget.
274#[allow(
275    clippy::implicit_hasher,
276    reason = "fallow standardizes on FxHashMap; fires on the lib target only, so #[expect] is unfulfilled on the bin"
277)]
278#[must_use]
279pub fn build_direction(
280    focus: &FocusMap,
281    out_of_diff_by_file: &FxHashMap<String, Vec<String>>,
282    routing: &RoutingFacts,
283) -> ReviewDirection {
284    // Optional expert overlay: file -> routed expert(s). Empty on the author's own
285    // PR, which is why it is an overlay and not the spine.
286    let expert_by_file: FxHashMap<&str, &[String]> = routing
287        .units
288        .iter()
289        .map(|unit| (unit.file.as_str(), unit.expert.as_slice()))
290        .collect();
291
292    let mut units: Vec<DirectionUnit> = focus
293        .review_here
294        .iter()
295        .chain(focus.deprioritized.iter())
296        .filter(|unit| is_reviewable_source_unit(&unit.file))
297        .map(|unit| {
298            // Per-unit out-of-diff: the consumers of THIS file outside the diff. A
299            // unit that breaks a contract gets the contract-break lens; the rest
300            // the plain orientation lens. Graph-derived.
301            let out_of_diff = out_of_diff_by_file
302                .get(&unit.file)
303                .cloned()
304                .unwrap_or_default();
305            let concern_lens = if out_of_diff.is_empty() {
306                "orientation".to_string()
307            } else {
308                "contract-break".to_string()
309            };
310            DirectionUnit {
311                file: unit.file.clone(),
312                concern_lens,
313                scoring_budget: unit.score.total,
314                out_of_diff,
315                expert: expert_by_file
316                    .get(unit.file.as_str())
317                    .map(|experts| experts.to_vec())
318                    .unwrap_or_default(),
319            }
320        })
321        .collect();
322
323    // Review the load-bearing units first: contract-breakers (out-of-diff
324    // consumers) ahead of the rest, ordered by their consumer count (the number
325    // each Stage-1 row shows as "consumed by N modules"). Within the
326    // non-contract-break tier (Stage 2, out-of-diff empty) the order follows the
327    // exact importer count each row displays as "high fan-in (N importers)" --
328    // descending fan-in, then descending fan-out, then path. We read those counts
329    // back out of the focus reason string (the canonical rendered text both
330    // surfaces print), so the top-to-bottom order is always the visible number,
331    // not the hidden, capped `fan_io` blend (a 60-importer file outranks a
332    // 5-importer file even though both cap to the same `fan_io`).
333    let fan_counts_by_file: FxHashMap<&str, (u32, u32)> = focus
334        .review_here
335        .iter()
336        .chain(focus.deprioritized.iter())
337        .map(|fu| (fu.file.as_str(), parse_fan_counts(&fu.reason)))
338        .collect();
339    let fan_counts =
340        |file: &str| -> (u32, u32) { fan_counts_by_file.get(file).copied().unwrap_or((0, 0)) };
341    units.sort_by(|a, b| {
342        let (a_in, a_out) = fan_counts(&a.file);
343        let (b_in, b_out) = fan_counts(&b.file);
344        b.out_of_diff
345            .len()
346            .cmp(&a.out_of_diff.len())
347            .then_with(|| b_in.cmp(&a_in))
348            .then_with(|| b_out.cmp(&a_out))
349            .then_with(|| a.file.cmp(&b.file))
350    });
351
352    let order = units.iter().map(|u| u.file.clone()).collect();
353    ReviewDirection { order, units }
354}
355
356/// Assemble the walkthrough guide from the assembled brief data. Pure over its
357/// inputs: the same digest + hash always produce the same guide.
358#[must_use]
359pub fn build_walkthrough_guide(
360    digest: ReviewBriefOutput,
361    graph_snapshot_hash: String,
362    direction: ReviewDirection,
363    change_anchors: Vec<ChangeAnchor>,
364) -> WalkthroughGuide {
365    WalkthroughGuide {
366        schema_version: ReviewBriefSchemaVersion::default(),
367        version: env!("CARGO_PKG_VERSION").to_string(),
368        command: "review-walkthrough-guide".to_string(),
369        graph_snapshot_hash,
370        digest,
371        direction,
372        change_anchors,
373        agent_schema: agent_schema(),
374        injection_note: INJECTION_NOTE,
375    }
376}
377
378/// Post-validate the agent's judgment JSON against the live graph.
379///
380/// The graph is the verifier:
381/// 1. If the agent's echoed `graph_snapshot_hash` != `current_hash`, the tree
382///    moved: REFUSE the whole payload as stale (accepted empty, every judgment
383///    rejected with `stale-snapshot`).
384/// 2. Otherwise, each judgment is ACCEPTED iff its `signal_id` is on the
385///    decision surface's emitted allowlist ([`DecisionSurface::accept_signal_id`]);
386///    an unanchored id is REJECTED (`unanchored-signal-id`). Accepted judgments
387///    carry the agent's framing FENCED as non-deterministic.
388#[must_use]
389#[allow(
390    clippy::implicit_hasher,
391    reason = "fallow standardizes on FxHashSet; the change-anchor allowlist is always built with the fallow hasher"
392)]
393pub fn validate_walkthrough(
394    agent: &AgentWalkthrough,
395    surface: &DecisionSurface,
396    change_anchor_ids: &FxHashSet<String>,
397    current_hash: &str,
398) -> WalkthroughValidation {
399    let stale = agent.graph_snapshot_hash != current_hash;
400
401    let mut accepted: Vec<AcceptedJudgment> = Vec::new();
402    let mut rejected: Vec<RejectedJudgment> = Vec::new();
403
404    if stale {
405        // Staleness refusal: the tree moved, so NOTHING the agent said can be
406        // trusted against this graph. Refuse the whole payload.
407        for judgment in &agent.judgments {
408            rejected.push(RejectedJudgment {
409                signal_id: judgment.signal_id.clone(),
410                change_anchor: judgment.change_anchor.clone(),
411                reason: "stale-snapshot".to_string(),
412            });
413        }
414    } else {
415        for judgment in &agent.judgments {
416            match validate_fresh_judgment(judgment, surface, change_anchor_ids) {
417                Ok(judgment) => accepted.push(judgment),
418                Err(judgment) => rejected.push(judgment),
419            }
420        }
421    }
422
423    let accepted_count = accepted.len();
424    let rejected_count = rejected.len();
425    // Every accepted judgment is anchored by construction (accept_signal_id was
426    // true), so the unanchored count among accepted is always zero. Surfaced as
427    // an explicit field so the done-condition asserts on it directly.
428    let unanchored_count = 0;
429
430    WalkthroughValidation {
431        schema_version: ReviewBriefSchemaVersion::default(),
432        version: env!("CARGO_PKG_VERSION").to_string(),
433        command: "review-walkthrough-validation".to_string(),
434        graph_snapshot_hash: current_hash.to_string(),
435        stale,
436        accepted,
437        rejected,
438        accepted_count,
439        rejected_count,
440        unanchored_count,
441    }
442}
443
444fn validate_fresh_judgment(
445    judgment: &AgentJudgment,
446    surface: &DecisionSurface,
447    change_anchor_ids: &FxHashSet<String>,
448) -> Result<AcceptedJudgment, RejectedJudgment> {
449    // A signal_id (graph finding) is the strong anchor; a change_anchor
450    // (changed region) is the weaker fallback. Prefer the signal.
451    if !judgment.signal_id.is_empty() && surface.accept_signal_id(&judgment.signal_id) {
452        return Ok(AcceptedJudgment {
453            signal_id: judgment.signal_id.clone(),
454            change_anchor: String::new(),
455            anchor_kind: "signal".to_string(),
456            agent_framing: judgment.framing.clone(),
457            concern: judgment.concern.clone(),
458            deterministic: false,
459        });
460    }
461    if !judgment.change_anchor.is_empty() && change_anchor_ids.contains(&judgment.change_anchor) {
462        return Ok(AcceptedJudgment {
463            signal_id: String::new(),
464            change_anchor: judgment.change_anchor.clone(),
465            anchor_kind: "change".to_string(),
466            agent_framing: judgment.framing.clone(),
467            concern: judgment.concern.clone(),
468            deterministic: false,
469        });
470    }
471
472    // Cited a change_anchor (but no valid signal_id) and it did not resolve:
473    // the region-level miss. Otherwise this is the signal-id miss.
474    let reason = if judgment.signal_id.is_empty() && !judgment.change_anchor.is_empty() {
475        UNKNOWN_CHANGE_ANCHOR_REASON
476    } else {
477        UNANCHORED_REASON
478    };
479    Err(RejectedJudgment {
480        signal_id: judgment.signal_id.clone(),
481        change_anchor: judgment.change_anchor.clone(),
482        reason: reason.to_string(),
483    })
484}
485
486/// Parse the agent's judgment JSON from a `--walkthrough-file` path's contents.
487/// A malformed payload yields an empty `AgentWalkthrough` whose default hash
488/// (`""`) will not match any real snapshot hash, so it is refused as stale (the
489/// safe direction: a garbled agent file never accepts).
490#[must_use]
491pub fn parse_agent_walkthrough(contents: &str) -> AgentWalkthrough {
492    serde_json::from_str(contents).unwrap_or_else(|_| AgentWalkthrough {
493        graph_snapshot_hash: String::new(),
494        judgments: Vec::new(),
495    })
496}
497
498/// Assemble the walkthrough guide from an [`crate::audit::AuditResult`] on the
499/// brief path. Reuses [`build_brief_output`] for the digest (graph-only, pure)
500/// and the retained routing + impact closure for the direction.
501#[must_use]
502pub fn build_guide_from_result(result: &crate::audit::AuditResult) -> WalkthroughGuide {
503    let digest = build_brief_output(result);
504    let hash = result.graph_snapshot_hash.clone().unwrap_or_default();
505    let empty_routing = RoutingFacts::default();
506    let routing = result.routing.as_ref().unwrap_or(&empty_routing);
507    // Per-file out-of-diff map from the (post-stories-filter) coordination gaps:
508    // each changed file -> the consumers outside the diff it actually affects, so
509    // every direction unit carries its OWN out-of-diff, not the global set.
510    let mut out_of_diff_by_file: FxHashMap<String, Vec<String>> = FxHashMap::default();
511    if let Some(closure) = result
512        .check
513        .as_ref()
514        .and_then(|c| c.impact_closure.as_ref())
515    {
516        for gap in &closure.coordination_gap {
517            out_of_diff_by_file
518                .entry(gap.changed_file.clone())
519                .or_default()
520                .push(gap.consumer_file.clone());
521        }
522        for consumers in out_of_diff_by_file.values_mut() {
523            consumers.sort();
524            consumers.dedup();
525        }
526    }
527    // Spine the direction on the CHANGE (the focus units), with routing as the
528    // optional expert overlay, so the work-list is never empty on the author's own
529    // PR. Borrow `digest.focus` before `digest` is moved into the guide.
530    let direction = build_direction(&digest.focus, &out_of_diff_by_file, routing);
531    build_walkthrough_guide(digest, hash, direction, result.change_anchors.clone())
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::audit::routing::RoutingUnit;
538    use crate::audit_brief::ReviewDeltas;
539    use crate::audit_decision_surface::{
540        BoundaryAnchor, DecisionCategory, DecisionInputs, derive_signal_id,
541        extract_decision_surface,
542    };
543
544    fn no_source(_: &str) -> Option<String> {
545        None
546    }
547
548    /// Build a synthetic decision surface with one coupling/boundary decision,
549    /// returning the surface plus the one real emitted signal id.
550    fn surface_with_one_signal() -> (DecisionSurface, String) {
551        let deltas = ReviewDeltas {
552            boundary_introduced: vec!["ui->-db".to_string()],
553            cycle_introduced: Vec::new(),
554            public_api_added: Vec::new(),
555        };
556        let anchors = vec![BoundaryAnchor {
557            zone_pair_key: "ui->-db".to_string(),
558            from_file: "src/ui/page.ts".to_string(),
559            from_zone: "ui".to_string(),
560            to_zone: "db".to_string(),
561            line: 1,
562        }];
563        let routing = RoutingFacts::default();
564        let surface = extract_decision_surface(&DecisionInputs {
565            deltas: &deltas,
566            boundary_anchors: &anchors,
567            coordination: &[],
568            public_api_anchor_line: 0,
569            affected_not_shown: 3,
570            routing: &routing,
571            head_source: &no_source,
572            rename_old_path: &no_source,
573            internal_consumers: &|_: &str| 0u64,
574            cap: 4,
575        });
576        let real_id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
577        (surface, real_id)
578    }
579
580    // Done-condition (a): a valid agent JSON citing only emitted signal_ids with
581    // the correct snapshot hash is ACCEPTED with zero unanchored findings.
582    #[test]
583    fn clean_agent_json_is_accepted_with_zero_unanchored() {
584        let (surface, real_id) = surface_with_one_signal();
585        let hash = "graph:abc123";
586        let agent = AgentWalkthrough {
587            graph_snapshot_hash: hash.to_string(),
588            judgments: vec![AgentJudgment {
589                signal_id: real_id.clone(),
590                change_anchor: String::new(),
591                framing: "Intended coupling, payments boundary widened on purpose.".to_string(),
592                concern: Some("coupling".to_string()),
593            }],
594        };
595        let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
596        assert!(!validation.stale, "matching hash is not stale");
597        assert_eq!(
598            validation.accepted_count, 1,
599            "the anchored judgment accepts"
600        );
601        assert_eq!(validation.rejected_count, 0, "no rejections");
602        assert_eq!(validation.unanchored_count, 0, "zero unanchored findings");
603        // The framing is fenced as non-deterministic.
604        assert!(!validation.accepted[0].deterministic);
605        assert_eq!(validation.accepted[0].signal_id, real_id);
606    }
607
608    // Done-condition (b): an injected unanchored finding is REJECTED.
609    #[test]
610    fn injected_unanchored_signal_id_is_rejected() {
611        let (surface, real_id) = surface_with_one_signal();
612        let hash = "graph:abc123";
613        let agent = AgentWalkthrough {
614            graph_snapshot_hash: hash.to_string(),
615            judgments: vec![
616                AgentJudgment {
617                    signal_id: real_id.clone(),
618                    change_anchor: String::new(),
619                    framing: "real".to_string(),
620                    concern: None,
621                },
622                AgentJudgment {
623                    // A fabricated id fallow never emitted.
624                    signal_id: "sig:deadbeefdeadbeef".to_string(),
625                    change_anchor: String::new(),
626                    framing: "hallucinated decision with no graph anchor".to_string(),
627                    concern: None,
628                },
629            ],
630        };
631        let validation = validate_walkthrough(&agent, &surface, &FxHashSet::default(), hash);
632        assert_eq!(validation.accepted_count, 1, "only the real one accepts");
633        assert_eq!(validation.rejected_count, 1, "the fabricated one rejects");
634        assert_eq!(validation.rejected[0].signal_id, "sig:deadbeefdeadbeef");
635        assert_eq!(validation.rejected[0].reason, UNANCHORED_REASON);
636        // The accepted set never contains the fabricated id.
637        assert!(
638            validation.accepted.iter().all(|j| j.signal_id == real_id),
639            "accepted excludes the unanchored id"
640        );
641    }
642
643    // Done-condition (c): stale JSON (mutated tree / old snapshot hash) is REFUSED.
644    #[test]
645    fn stale_snapshot_hash_refuses_the_whole_payload() {
646        let (surface, real_id) = surface_with_one_signal();
647        let current_hash = "graph:NEW_after_mutation";
648        // The agent echoed the OLD hash (the tree moved since the guide).
649        let agent = AgentWalkthrough {
650            graph_snapshot_hash: "graph:OLD_before_mutation".to_string(),
651            judgments: vec![AgentJudgment {
652                // Even a real signal id is refused under a stale snapshot.
653                signal_id: real_id,
654                change_anchor: String::new(),
655                framing: "would be valid, but the tree moved".to_string(),
656                concern: None,
657            }],
658        };
659        let validation =
660            validate_walkthrough(&agent, &surface, &FxHashSet::default(), current_hash);
661        assert!(validation.stale, "old hash is stale");
662        assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
663        assert_eq!(validation.rejected_count, 1, "the judgment is refused");
664        assert_eq!(validation.rejected[0].reason, "stale-snapshot");
665    }
666
667    #[test]
668    fn malformed_agent_json_parses_to_a_stale_refusal() {
669        let agent = parse_agent_walkthrough("{not valid json");
670        assert!(agent.graph_snapshot_hash.is_empty());
671        assert!(agent.judgments.is_empty());
672        let (surface, _) = surface_with_one_signal();
673        let validation =
674            validate_walkthrough(&agent, &surface, &FxHashSet::default(), "graph:real");
675        assert!(
676            validation.stale,
677            "empty echoed hash never matches a real hash"
678        );
679        assert_eq!(validation.accepted_count, 0);
680    }
681
682    fn focus_unit(file: &str, total: u32) -> crate::audit_focus::FocusUnit {
683        crate::audit_focus::FocusUnit {
684            file: file.to_string(),
685            score: crate::audit_focus::FocusScore {
686                total,
687                ..Default::default()
688            },
689            label: crate::audit_focus::FocusLabel::ReviewHere,
690            reason: String::new(),
691            confidence: Vec::new(),
692        }
693    }
694
695    /// A focus unit whose `reason` carries a displayed importer count (the exact
696    /// "high fan-in (N importers)" phrasing `build_reason` emits) AND a deliberately
697    /// disagreeing, capped `fan_io` + composite `total`, so a test can prove the
698    /// within-stage order follows the DISPLAYED importer count, not the hidden blend.
699    fn focus_unit_fan(
700        file: &str,
701        importers: u32,
702        fan_io: u32,
703        total: u32,
704    ) -> crate::audit_focus::FocusUnit {
705        let s = if importers == 1 { "" } else { "s" };
706        crate::audit_focus::FocusUnit {
707            file: file.to_string(),
708            score: crate::audit_focus::FocusScore {
709                fan_io,
710                total,
711                ..Default::default()
712            },
713            label: crate::audit_focus::FocusLabel::ReviewHere,
714            reason: format!("high fan-in ({importers} importer{s})"),
715            confidence: Vec::new(),
716        }
717    }
718
719    // ORDERING PRINCIPLE: within Stage 2 the order is the DISPLAYED importer count
720    // ("high fan-in (N importers)"), NOT the hidden, capped `fan_io` blend. Here
721    // a.ts shows 5 importers, b.ts shows 60 -- but both cap to the SAME `fan_io`
722    // and a.ts even has the larger composite `total`. Neither has out-of-diff
723    // consumers (both Stage 2), so the order must follow the shown importer count:
724    // the 60-importer file first.
725    #[test]
726    fn within_stage_order_follows_visible_fan_io_not_hidden_total() {
727        let focus = FocusMap {
728            review_here: vec![
729                // a.ts: 5 importers shown, fan_io capped to 8, total 99 (hidden high).
730                focus_unit_fan("src/a.ts", 5, 8, 99),
731                // b.ts: 60 importers shown, fan_io ALSO capped to 8, total 1.
732                focus_unit_fan("src/b.ts", 60, 8, 1),
733            ],
734            deprioritized: vec![],
735        };
736        let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
737        // The displayed importer count decides: b.ts (60 importers) before a.ts (5),
738        // even though they share a `fan_io` and a.ts has the larger composite total.
739        assert_eq!(direction.order, vec!["src/b.ts", "src/a.ts"]);
740    }
741
742    // Reading top-to-bottom, the shown importer count is non-increasing, and
743    // fan-out breaks ties among equal-importer files (here zero-importer files).
744    #[test]
745    fn stage_two_orders_by_importer_then_fanout_then_path() {
746        let focus = FocusMap {
747            review_here: vec![
748                // Two zero-importer files separated only by fan-out, plus one
749                // high-importer file that must lead regardless.
750                crate::audit_focus::FocusUnit {
751                    file: "src/zlo.ts".to_string(),
752                    score: crate::audit_focus::FocusScore::default(),
753                    label: crate::audit_focus::FocusLabel::ReviewHere,
754                    reason: "fan-out 1".to_string(),
755                    confidence: Vec::new(),
756                },
757                crate::audit_focus::FocusUnit {
758                    file: "src/zhi.ts".to_string(),
759                    score: crate::audit_focus::FocusScore::default(),
760                    label: crate::audit_focus::FocusLabel::ReviewHere,
761                    reason: "fan-out 9".to_string(),
762                    confidence: Vec::new(),
763                },
764                focus_unit_fan("src/top.ts", 12, 8, 1),
765            ],
766            deprioritized: vec![],
767        };
768        let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
769        // top.ts (12 importers) leads; then the zero-importer files by fan-out
770        // (9 before 1), so the displayed counts are non-increasing top to bottom.
771        assert_eq!(
772            direction.order,
773            vec!["src/top.ts", "src/zhi.ts", "src/zlo.ts"]
774        );
775    }
776
777    // Contract-breakers (out-of-diff consumers) remain the strict first priority
778    // class regardless of importer count: an out-of-diff unit sorts ahead of a
779    // higher-fan-in orientation unit.
780    #[test]
781    fn out_of_diff_units_sort_ahead_of_higher_fan_io_orientation_units() {
782        let focus = FocusMap {
783            review_here: vec![
784                // contract.ts shows just 1 importer; orient.ts shows 9.
785                focus_unit_fan("src/contract.ts", 1, 1, 1),
786                focus_unit_fan("src/orient.ts", 9, 9, 9),
787            ],
788            deprioritized: vec![],
789        };
790        let mut out_of_diff = FxHashMap::default();
791        out_of_diff.insert(
792            "src/contract.ts".to_string(),
793            vec!["src/consumer.ts".to_string()],
794        );
795        let direction = build_direction(&focus, &out_of_diff, &RoutingFacts::default());
796        // contract.ts breaks a contract -> sorts first even with the lower importer
797        // count.
798        assert_eq!(direction.order, vec!["src/contract.ts", "src/orient.ts"]);
799        assert_eq!(direction.units[0].concern_lens, "contract-break");
800    }
801
802    #[test]
803    fn direction_spines_on_focus_units_with_expert_overlay() {
804        // The SPINE is the change (focus units), never the routing. The author's
805        // own PR has expert: [] on every routing unit, yet the direction still
806        // enumerates the units. b.ts has a real expert overlay; a.ts has none.
807        let focus = FocusMap {
808            review_here: vec![focus_unit("src/b.ts", 5), focus_unit("src/a.ts", 3)],
809            deprioritized: vec![],
810        };
811        let routing = RoutingFacts {
812            units: vec![RoutingUnit {
813                file: "src/b.ts".to_string(),
814                expert: vec!["@team".to_string()],
815                bus_factor_one: false,
816            }],
817        };
818        // Only src/a.ts has an out-of-diff consumer; src/b.ts has none.
819        let mut out_of_diff_by_file = FxHashMap::default();
820        out_of_diff_by_file.insert("src/a.ts".to_string(), vec!["src/consumer.ts".to_string()]);
821        let direction = build_direction(&focus, &out_of_diff_by_file, &routing);
822        // a.ts breaks a contract -> sorts first with the contract-break lens,
823        // carrying its budget; b.ts has no out-of-diff -> orientation, but the
824        // expert overlay still attaches @team.
825        assert_eq!(direction.order, vec!["src/a.ts", "src/b.ts"]);
826        assert_eq!(direction.units[0].file, "src/a.ts");
827        assert_eq!(direction.units[0].concern_lens, "contract-break");
828        assert_eq!(direction.units[0].out_of_diff, vec!["src/consumer.ts"]);
829        assert_eq!(direction.units[0].scoring_budget, 3);
830        assert!(direction.units[0].expert.is_empty());
831        assert_eq!(direction.units[1].file, "src/b.ts");
832        assert_eq!(direction.units[1].concern_lens, "orientation");
833        assert_eq!(direction.units[1].scoring_budget, 5);
834        assert_eq!(direction.units[1].expert, vec!["@team".to_string()]);
835    }
836
837    #[test]
838    fn direction_excludes_non_source_units() {
839        let focus = FocusMap {
840            review_here: vec![
841                focus_unit("LICENSE", 1),
842                focus_unit(".gitignore", 1),
843                focus_unit("README.md", 1),
844                focus_unit("src/app.component.ts", 4),
845            ],
846            deprioritized: vec![],
847        };
848        let direction = build_direction(&focus, &FxHashMap::default(), &RoutingFacts::default());
849        // Only the source unit survives; docs/config/license churn is dropped.
850        assert_eq!(direction.order, vec!["src/app.component.ts"]);
851        assert_eq!(direction.units[0].concern_lens, "orientation");
852        assert_eq!(direction.units[0].scoring_budget, 4);
853    }
854
855    #[test]
856    fn guide_carries_the_snapshot_hash_and_injection_note() {
857        let digest = ReviewBriefOutput {
858            schema_version: ReviewBriefSchemaVersion::default(),
859            version: "test".to_string(),
860            command: "audit-brief".to_string(),
861            triage: crate::audit_brief::DiffTriage {
862                files: 0,
863                hunks: None,
864                net_lines: None,
865                risk_class: crate::audit_brief::RiskClass::Low,
866                review_effort: crate::audit_brief::ReviewEffort::Glance,
867            },
868            graph_facts: crate::audit_brief::GraphFacts {
869                exports_added: 0,
870                api_width_delta: 0,
871                reachable_from: Vec::new(),
872                boundaries_touched: Vec::new(),
873            },
874            partition: crate::audit_brief::PartitionFacts::default(),
875            impact_closure: crate::audit_brief::ImpactClosureFacts::default(),
876            focus: crate::audit_focus::FocusMap::default(),
877            deltas: ReviewDeltas::default(),
878            weakening: Vec::new(),
879            routing: RoutingFacts::default(),
880            decisions: DecisionSurface::default(),
881        };
882        let guide = build_walkthrough_guide(
883            digest,
884            "graph:pinned".to_string(),
885            ReviewDirection::default(),
886            Vec::new(),
887        );
888        assert_eq!(guide.graph_snapshot_hash, "graph:pinned");
889        assert!(guide.injection_note.contains("untrusted"));
890        assert_eq!(guide.command, "review-walkthrough-guide");
891        assert!(guide.agent_schema.anchoring_rule.contains("rejected"));
892    }
893
894    // change_anchor: a content-addressed id is stable across line-shifts and
895    // whitespace-only edits, and namespaced under `chg:`.
896    #[test]
897    fn derive_change_anchor_id_is_stable_and_namespaced() {
898        let added = vec!["const x = 1;".to_string(), "return x;".to_string()];
899        let normalized = normalize_added_text(&added);
900        let id = derive_change_anchor_id("src/a.ts", &normalized, 0);
901        assert!(id.starts_with("chg:"), "namespaced under chg:");
902        // Same content at a DIFFERENT line (the start line is not hashed) -> same id.
903        assert_eq!(id, derive_change_anchor_id("src/a.ts", &normalized, 0));
904        // A whitespace-only reflow normalizes to the same text -> same id.
905        let reflowed = vec!["  const x = 1;  ".to_string(), "\treturn x;".to_string()];
906        assert_eq!(
907            id,
908            derive_change_anchor_id("src/a.ts", &normalize_added_text(&reflowed), 0)
909        );
910        // Different added text -> different id.
911        assert_ne!(
912            id,
913            derive_change_anchor_id(
914                "src/a.ts",
915                &normalize_added_text(&["const y = 2;".to_string()]),
916                0
917            )
918        );
919        // Same text in a different file -> different id.
920        assert_ne!(id, derive_change_anchor_id("src/b.ts", &normalized, 0));
921    }
922
923    // change_anchor: parsing a unified diff yields one anchor per hunk; an edit
924    // ABOVE a hunk shifts its start_line but NOT its content-addressed id.
925    #[test]
926    fn parse_change_anchors_is_line_shift_stable() {
927        let diff_a = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -10,0 +11,1 @@\n+  const added = compute();\n";
928        let diff_b = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -40,0 +41,1 @@\n+  const added = compute();\n";
929        let a = parse_change_anchors(diff_a);
930        let b = parse_change_anchors(diff_b);
931        assert_eq!(a.len(), 1);
932        assert_eq!(b.len(), 1);
933        assert_eq!(
934            a[0].change_anchor, b[0].change_anchor,
935            "id is line-shift stable"
936        );
937        assert_eq!(a[0].start_line, 11);
938        assert_eq!(b[0].start_line, 41, "start_line tracks the new position");
939    }
940
941    // change_anchor: a judgment citing an emitted change_anchor is ACCEPTED with
942    // anchor_kind=change; an unknown change_anchor is REJECTED.
943    #[test]
944    fn change_anchor_judgment_accepts_and_unknown_rejects() {
945        let (surface, _) = surface_with_one_signal();
946        let hash = "graph:abc123";
947        let diff = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1,0 +2,1 @@\n+  const added = compute();\n";
948        let anchors = parse_change_anchors(diff);
949        let allow = change_anchor_allowlist(&anchors);
950        let real = anchors[0].change_anchor.clone();
951        let agent = AgentWalkthrough {
952            graph_snapshot_hash: hash.to_string(),
953            judgments: vec![
954                AgentJudgment {
955                    signal_id: String::new(),
956                    change_anchor: real.clone(),
957                    framing: "this region trades simplicity for a cache".to_string(),
958                    concern: None,
959                },
960                AgentJudgment {
961                    signal_id: String::new(),
962                    change_anchor: "chg:deadbeefdeadbeef".to_string(),
963                    framing: "hallucinated region".to_string(),
964                    concern: None,
965                },
966            ],
967        };
968        let validation = validate_walkthrough(&agent, &surface, &allow, hash);
969        assert_eq!(
970            validation.accepted_count, 1,
971            "the real change_anchor accepts"
972        );
973        assert_eq!(validation.accepted[0].anchor_kind, "change");
974        assert_eq!(validation.accepted[0].change_anchor, real);
975        assert!(validation.accepted[0].signal_id.is_empty());
976        assert!(!validation.accepted[0].deterministic);
977        assert_eq!(
978            validation.rejected_count, 1,
979            "the fabricated region rejects"
980        );
981        assert_eq!(validation.rejected[0].reason, UNKNOWN_CHANGE_ANCHOR_REASON);
982        assert_eq!(validation.rejected[0].change_anchor, "chg:deadbeefdeadbeef");
983    }
984
985    // change_anchor: a stale snapshot refuses a change_anchor judgment too.
986    #[test]
987    fn stale_snapshot_refuses_change_anchor_judgment() {
988        let (surface, _) = surface_with_one_signal();
989        let diff = "diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1,0 +2,1 @@\n+  const added = compute();\n";
990        let anchors = parse_change_anchors(diff);
991        let allow = change_anchor_allowlist(&anchors);
992        let agent = AgentWalkthrough {
993            graph_snapshot_hash: "graph:OLD".to_string(),
994            judgments: vec![AgentJudgment {
995                signal_id: String::new(),
996                change_anchor: anchors[0].change_anchor.clone(),
997                framing: "valid region, but the tree moved".to_string(),
998                concern: None,
999            }],
1000        };
1001        let validation = validate_walkthrough(&agent, &surface, &allow, "graph:NEW");
1002        assert!(validation.stale);
1003        assert_eq!(validation.accepted_count, 0, "nothing accepts when stale");
1004        assert_eq!(validation.rejected[0].reason, "stale-snapshot");
1005    }
1006
1007    // change_anchor: a renamed file's anchor resolves via previous_change_anchor,
1008    // so an agent that cited the pre-rename id still anchors.
1009    #[test]
1010    fn change_anchor_survives_rename_via_previous_anchor() {
1011        let renamed = "diff --git a/src/old.ts b/src/new.ts\nrename from src/old.ts\nrename to src/new.ts\n--- a/src/old.ts\n+++ b/src/new.ts\n@@ -1,0 +2,1 @@\n+  const added = compute();\n";
1012        let anchors = parse_change_anchors(renamed);
1013        assert_eq!(anchors.len(), 1);
1014        assert_eq!(anchors[0].file, "src/new.ts");
1015        let previous = anchors[0]
1016            .previous_change_anchor
1017            .clone()
1018            .expect("rename yields a previous anchor");
1019        // The previous id equals what the same hunk under the OLD path would emit.
1020        let old_diff = "diff --git a/src/old.ts b/src/old.ts\n--- a/src/old.ts\n+++ b/src/old.ts\n@@ -1,0 +2,1 @@\n+  const added = compute();\n";
1021        let old_anchors = parse_change_anchors(old_diff);
1022        assert_eq!(previous, old_anchors[0].change_anchor);
1023        // The allowlist contains BOTH the new id and the pre-rename id.
1024        let allow = change_anchor_allowlist(&anchors);
1025        assert!(
1026            allow.contains(&previous),
1027            "pre-rename id is in the allowlist"
1028        );
1029        assert!(allow.contains(&anchors[0].change_anchor));
1030    }
1031}