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