Skip to main content

newt_core/
verify_gate.rs

1//! The verify gate (#73) — the harness's red-squiggle for fabricated imports,
2//! and R2's decision core.
3//!
4//! After a coding turn, resolve the produced Python files' imports against the
5//! authoritative surface (the R1 [`FfiManifest`](crate::ffi_manifest::FfiManifest)
6//! `known_modules()`, or any module set) and decide which files to revert. The
7//! decision is **file-scoped**: the with-manifest runs
8//! (`docs/findings/2026-06-14-fabrication-...`) showed nemotron, even when handed
9//! the surface, *hedges* — writing whole grounded files alongside whole
10//! fabricated ones. Reverting the fabricated **file** (and retrying just it) is
11//! the surgical fix, and it composes with R1: R1 raises per-import grounding, the
12//! gate deletes the residual fabrications.
13//!
14//! Resolution shares its primitives with the scorer
15//! ([`module_is_known`](crate::symbols::module_is_known),
16//! [`python_stdlib_modules`](crate::symbols::python_stdlib_modules)) but the gate
17//! is deliberately **stricter**: as a *control* signal in a retry loop it matches
18//! the project surface leaf-[`Exact`](SurfaceMatch::Exact) by default, where the
19//! scorer prefix-matches its coarser hand-written surface. The retry-Goodhart
20//! finding (`docs/findings/2026-06-15-retry-and-the-honest-gate.md`) is why: a
21//! control gate must be adversarially complete or the model games its blind
22//! spots. Symbol-level resolution still follows the FFI manifest (#74).
23
24use async_trait::async_trait;
25use std::cell::RefCell;
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::{Path, PathBuf};
28
29use crate::symbols::{extract_references, module_is_known, python_stdlib_modules, Lang};
30use serde::{Deserialize, Serialize};
31
32/// One fabricated reference: the module imported and the line it sat on.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Fabrication {
35    /// The fabricated module path (e.g. `"newt_core"`).
36    pub module: String,
37    /// 1-based source line, so the gate can point at it.
38    pub line: usize,
39}
40
41/// The gate's verdict for one produced file.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct FileVerdict {
44    /// The file, relative to the gated workspace root.
45    pub path: PathBuf,
46    /// Fabricated module imports in this file — empty iff the file is clean.
47    pub fabrications: Vec<Fabrication>,
48}
49
50impl FileVerdict {
51    /// No fabricated imports — the file is accepted as-is.
52    #[must_use]
53    pub fn is_clean(&self) -> bool {
54        self.fabrications.is_empty()
55    }
56}
57
58/// The gate's decision over a produced workspace.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct GateReport {
61    /// One entry per gated file, in sorted path order.
62    pub files: Vec<FileVerdict>,
63}
64
65impl GateReport {
66    /// The revert set — files with at least one fabricated import. These are the
67    /// files R2 reverts and retries; a clean file is never touched.
68    #[must_use]
69    pub fn revert_set(&self) -> Vec<&Path> {
70        self.files
71            .iter()
72            .filter(|f| !f.is_clean())
73            .map(|f| f.path.as_path())
74            .collect()
75    }
76
77    /// Accept the turn as-is — true iff no file fabricated.
78    #[must_use]
79    pub fn accept(&self) -> bool {
80        self.files.iter().all(FileVerdict::is_clean)
81    }
82
83    /// Total fabricated imports across all files.
84    #[must_use]
85    pub fn fabrication_count(&self) -> usize {
86        self.files.iter().map(|f| f.fabrications.len()).sum()
87    }
88}
89
90/// How strictly the project surface is matched — a tunable knob.
91///
92/// The retry-Goodhart finding
93/// (`docs/findings/2026-06-15-retry-and-the-honest-gate.md`) showed `Prefix` is
94/// exploitable once the gate *controls* a retry loop rather than merely
95/// *measuring*: `newt_agent._newt_core` passes via the real `newt_agent` prefix,
96/// so the model, under retry pressure, drifts into that blind spot. `Exact` (the
97/// default) requires the import to be a module the surface actually declares —
98/// sound because R1's manifest carries the full leaf+ancestor set. A coarse,
99/// hand-written surface that lists only roots may still want `Prefix`.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum SurfaceMatch {
103    /// The module must be an exact member of the project surface.
104    #[default]
105    Exact,
106    /// The module — or any dotted prefix — is in the surface (lax; legacy).
107    Prefix,
108}
109
110/// How strictly the verify gate acts on a turn's flagged output — the **tier**.
111/// `RevertRetry` is today's behavior; lower tiers trade enforcement for latitude
112/// (a per-model/profile knob).
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum VerifyTier {
116    /// Gate disabled — flagged output is left as-is.
117    Off,
118    /// Measure + warn only; never revert (non-destructive).
119    Advisory,
120    /// Revert the flagged files; do not re-prompt.
121    RevertOnce,
122    /// Revert the flagged files AND issue a corrective retry (today's behavior).
123    #[default]
124    RevertRetry,
125}
126
127/// What a [`VerifyTier`] decides to do, given a [`GateReport`]. A **pure decision**
128/// — the caller (the turn loop) executes the revert/retry.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum VerifyAction {
131    /// Nothing flagged (or the gate is `Off`) — accept the turn.
132    Pass,
133    /// Report the fabrications; leave the files in place (advisory).
134    Warn,
135    /// Revert the flagged files; do not retry.
136    Revert,
137    /// Revert the flagged files and issue a corrective re-prompt.
138    RevertAndRetry,
139}
140
141impl VerifyTier {
142    /// The action this tier takes for `report` (pure; the caller executes it).
143    #[must_use]
144    pub fn action(self, report: &GateReport) -> VerifyAction {
145        if report.accept() {
146            return VerifyAction::Pass; // nothing fabricated
147        }
148        match self {
149            Self::Off => VerifyAction::Pass,
150            Self::Advisory => VerifyAction::Warn,
151            Self::RevertOnce => VerifyAction::Revert,
152            Self::RevertRetry => VerifyAction::RevertAndRetry,
153        }
154    }
155}
156
157/// The honest end-of-turn banner. A turn that did **not** finish cleanly — because
158/// the verify gate reverted work (`reverted`), and/or the tool-round cap was hit
159/// (`cap_hit`) — gets ONE `needs human review` line naming *both* facts, never a
160/// false success. Returns `None` when the turn finished cleanly (nothing reverted,
161/// no cap hit). `gave_up` marks revert-retry exhaustion.
162#[must_use]
163pub fn turn_verdict_banner(reverted: &[String], gave_up: bool, cap_hit: bool) -> Option<String> {
164    if reverted.is_empty() && !cap_hit {
165        return None;
166    }
167    let mut parts = Vec::new();
168    if !reverted.is_empty() {
169        parts.push(format!(
170            "the verify gate reverted {} file(s) [{}]{}",
171            reverted.len(),
172            reverted.join(", "),
173            if gave_up {
174                " after exhausting retries"
175            } else {
176                ""
177            }
178        ));
179    }
180    if cap_hit {
181        parts.push("the tool-round cap was reached".to_string());
182    }
183    Some(format!(
184        "needs human review: {} — the turn did not finish cleanly.",
185        parts.join("; and ")
186    ))
187}
188
189/// Is `module` resolvable? The project `surface` is matched per `mode`; the
190/// Python stdlib is always prefix-matched (`os` covers `os.path`).
191fn module_resolves(
192    module: &str,
193    surface: &BTreeSet<String>,
194    stdlib: &BTreeSet<String>,
195    mode: SurfaceMatch,
196) -> bool {
197    let in_surface = match mode {
198        SurfaceMatch::Exact => surface.contains(module),
199        SurfaceMatch::Prefix => module_is_known(module, surface),
200    };
201    in_surface || module_is_known(module, stdlib)
202}
203
204/// Gate one Python source against the project `surface` with the default
205/// (`Exact`) strictness; the Python stdlib is always known.
206#[must_use]
207pub fn gate_python_source(
208    path: impl Into<PathBuf>,
209    source: &str,
210    surface: &BTreeSet<String>,
211) -> FileVerdict {
212    gate_python_source_with(path, source, surface, SurfaceMatch::default())
213}
214
215/// Gate one Python source, choosing the surface-match strictness.
216#[must_use]
217pub fn gate_python_source_with(
218    path: impl Into<PathBuf>,
219    source: &str,
220    surface: &BTreeSet<String>,
221    mode: SurfaceMatch,
222) -> FileVerdict {
223    gate_inner(path, source, surface, &python_stdlib_modules(), mode)
224}
225
226/// Per-file core. The stdlib set is passed in so the workspace walk computes it
227/// once, not per file.
228fn gate_inner(
229    path: impl Into<PathBuf>,
230    source: &str,
231    surface: &BTreeSet<String>,
232    stdlib: &BTreeSet<String>,
233    mode: SurfaceMatch,
234) -> FileVerdict {
235    let mut fabrications: Vec<Fabrication> = extract_references(source, Lang::Python)
236        .into_iter()
237        .filter(|r| !module_resolves(&r.module, surface, stdlib, mode))
238        .map(|r| Fabrication {
239            module: r.module,
240            line: r.line,
241        })
242        .collect();
243    // One fabricated module imported as several symbols on one line is one
244    // fabrication, not one-per-symbol. References from a line are emitted
245    // consecutively, so a consecutive-dedup on (module, line) suffices.
246    fabrications.dedup_by(|a, b| a.module == b.module && a.line == b.line);
247    FileVerdict {
248        path: path.into(),
249        fabrications,
250    }
251}
252
253/// Gate every `.py` file under `workspace` against `surface` with the default
254/// (`Exact`) strictness. Files are returned in sorted (deterministic) path order;
255/// paths are relative to `workspace`.
256///
257/// # Errors
258/// Propagates I/O errors from reading the workspace tree.
259pub fn gate_python_workspace(
260    workspace: &Path,
261    surface: &BTreeSet<String>,
262) -> std::io::Result<GateReport> {
263    gate_python_workspace_with(workspace, surface, SurfaceMatch::default())
264}
265
266/// Gate every `.py` file under `workspace`, choosing the surface-match strictness.
267///
268/// # Errors
269/// Propagates I/O errors from reading the workspace tree.
270pub fn gate_python_workspace_with(
271    workspace: &Path,
272    surface: &BTreeSet<String>,
273    mode: SurfaceMatch,
274) -> std::io::Result<GateReport> {
275    let stdlib = python_stdlib_modules();
276    let mut py_files = Vec::new();
277    collect_py_files(workspace, &mut py_files)?;
278    py_files.sort();
279
280    let mut files = Vec::new();
281    for abs in py_files {
282        let source = std::fs::read_to_string(&abs)?;
283        let rel = abs.strip_prefix(workspace).unwrap_or(&abs).to_path_buf();
284        files.push(gate_inner(rel, &source, surface, &stdlib, mode));
285    }
286    Ok(GateReport { files })
287}
288
289/// Directory names never walked by the gate: dependency/build trees that are not
290/// the project's own source. Walking them gates thousands of installed `.py` (perf)
291/// and would let the gate *report* fabrications in code newt never wrote (noise).
292/// The `retry` revert is already fenced to newt's own writes by the ledger, but
293/// keeping these out of the walk is defense-in-depth and a real speedup.
294const SKIP_DIRS: &[&str] = &[
295    ".git",
296    ".venv",
297    "venv",
298    "site-packages",
299    "node_modules",
300    "target",
301    "__pycache__",
302    ".tox",
303    ".mypy_cache",
304];
305
306/// Recursively collect `*.py` paths under `dir`.
307///
308/// **Symlinks are not followed** — neither symlinked directories nor symlinked
309/// files are entered or collected. This is a hard safety boundary: a symlinked dir
310/// could otherwise pull external `.py` into the gate (and, were they ever reverted,
311/// let a write/delete escape the workspace). Vendored/build dirs ([`SKIP_DIRS`]) are
312/// skipped too.
313fn collect_py_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
314    for entry in std::fs::read_dir(dir)? {
315        let entry = entry?;
316        // `file_type()` does NOT traverse the final symlink, so a symlinked dir or
317        // file is identified as a symlink here and skipped before we ever follow it.
318        let ft = entry.file_type()?;
319        if ft.is_symlink() {
320            continue;
321        }
322        let path = entry.path();
323        if ft.is_dir() {
324            let skip = path
325                .file_name()
326                .and_then(|n| n.to_str())
327                .is_some_and(|n| SKIP_DIRS.contains(&n));
328            if !skip {
329                collect_py_files(&path, out)?;
330            }
331        } else if path.extension().is_some_and(|e| e == "py") {
332            out.push(path);
333        }
334    }
335    Ok(())
336}
337
338// ── The `retry` technique (R2's action arm) ─────────────────────────────────
339//
340// `verify_gate` (above) *measures*; `retry` *acts* on the measurement: it reverts
341// exactly [`GateReport::revert_set`] to its pre-turn state and re-prompts the
342// model, up to a cap, then gives up honestly. Contract:
343// `docs/design/retry-technique.md`. This module is the pure mechanism (the live
344// loop wiring threads [`WriteLedger`] through the write-tool seam separately).
345//
346// NB: the technique is *named* `retry` in a profile's config, but lives here —
347// **not** in `crate::retry` (that is the unrelated HTTP backoff module).
348
349/// A turn-scoped copy-on-first-write ledger: the record that lets `retry` revert a
350/// fabricated file to the state it had **before the turn began**.
351///
352/// The first time a path is written during a turn, its prior bytes are captured
353/// (`Some` = the file existed; `None` = it did not). Later writes to the same path
354/// in the same turn do **not** overwrite that entry — the *pre-turn* state is the
355/// revert target, never an intermediate write. The rig instrument reverted with a
356/// bare `rm` (sound only because its corpus files never pre-existed); in the real
357/// loop a flagged file may be an *edit* of a tracked file, so the ledger restores
358/// bytes rather than deleting. Git-independent by design — newt gates non-git
359/// trees too (`docs/design/retry-technique.md`).
360#[derive(Debug, Default, Clone)]
361pub struct WriteLedger {
362    /// Absolute path → pre-turn content (`None` ⇒ the path did not exist pre-turn).
363    entries: BTreeMap<PathBuf, Option<Vec<u8>>>,
364}
365
366impl WriteLedger {
367    /// An empty ledger for a fresh turn.
368    #[must_use]
369    pub fn new() -> Self {
370        Self::default()
371    }
372
373    /// Record `path`'s pre-turn state the **first** time it is written this turn.
374    /// Call this *before* the write lands. A no-op on any subsequent write to the
375    /// same path — the pre-turn snapshot is preserved.
376    ///
377    /// Only a genuine `NotFound` records the "did-not-exist" marker (`None`) that
378    /// makes revert *delete* the path. Any **other** read error (`EACCES`, a
379    /// transient NFS hiccup, the path being a directory) leaves the path
380    /// **untracked** — conflating "unreadable" with "absent" would let revert delete
381    /// a pre-existing file whose note-time read merely failed. Untracked ⇒ revert
382    /// returns `false` and refuses to touch it.
383    pub fn note_before_write(&mut self, path: impl Into<PathBuf>) {
384        let path = path.into();
385        if self.entries.contains_key(&path) {
386            return;
387        }
388        let prior = match std::fs::read(&path) {
389            Ok(bytes) => Some(bytes),
390            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
391            Err(_) => return, // unreadable ≠ absent: never mark it delete-on-revert
392        };
393        self.entries.insert(path, prior);
394    }
395
396    /// Restore `path` to its recorded pre-turn state: write the captured bytes
397    /// back, or remove the file if it did not exist pre-turn. Returns `true` iff
398    /// the path had a ledger entry (so the caller can warn on a gate-flagged path
399    /// the ledger never saw — a bug, not a silent delete of an untracked file).
400    ///
401    /// # Errors
402    /// Propagates I/O errors from the restore write or remove (a `NotFound` on
403    /// remove is treated as already-reverted, not an error).
404    pub fn revert(&self, path: &Path) -> std::io::Result<bool> {
405        match self.entries.get(path) {
406            None => Ok(false),
407            Some(None) => match std::fs::remove_file(path) {
408                Ok(()) => Ok(true),
409                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
410                Err(e) => Err(e),
411            },
412            Some(Some(bytes)) => {
413                std::fs::write(path, bytes)?;
414                Ok(true)
415            }
416        }
417    }
418
419    /// Whether nothing has been written this turn.
420    #[must_use]
421    pub fn is_empty(&self) -> bool {
422        self.entries.is_empty()
423    }
424
425    /// How many distinct paths were written this turn.
426    #[must_use]
427    pub fn len(&self) -> usize {
428        self.entries.len()
429    }
430}
431
432/// One corrective model turn, abstracted so the loop is testable without a live
433/// backend. The implementor applies `corrective_prompt`, writes any produced files
434/// into the workspace, **and records each write in the shared [`WriteLedger`]** (so
435/// a file a retry newly creates can itself be reverted by the next iteration).
436///
437/// `?Send` because the live caller is the single-threaded TUI loop (`run_chat`
438/// drives each step through a discrete `block_on`); the future never crosses
439/// threads.
440#[async_trait(?Send)]
441pub trait RetryRerun {
442    /// Run one grounded re-attempt for the given corrective prompt.
443    ///
444    /// # Errors
445    /// Returns the backend/turn error; `apply_revert_retry` propagates it (a failed
446    /// re-attempt aborts the loop rather than masking the failure).
447    async fn rerun(&mut self, corrective_prompt: String) -> anyhow::Result<()>;
448}
449
450/// The result of a verify-gated revert-retry loop.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct RetryOutcome {
453    /// `true` iff the workspace ended clean (the gate accepts) within the cap.
454    pub accepted: bool,
455    /// How many re-attempts were actually run (`0..=max_retries`).
456    pub retries_used: u32,
457    /// Files left reverted at give-up, relative to the workspace (empty iff
458    /// `accepted`).
459    pub reverted: Vec<PathBuf>,
460    /// The fabricated modules still outstanding at give-up, de-duplicated and
461    /// sorted (empty iff `accepted`).
462    pub outstanding_modules: Vec<String>,
463}
464
465impl RetryOutcome {
466    fn accepted(retries_used: u32) -> Self {
467        Self {
468            accepted: true,
469            retries_used,
470            reverted: Vec::new(),
471            outstanding_modules: Vec::new(),
472        }
473    }
474}
475
476/// The distinct fabricated modules across a report, de-duplicated and sorted.
477fn outstanding_modules(report: &GateReport) -> Vec<String> {
478    report
479        .files
480        .iter()
481        .flat_map(|f| f.fabrications.iter().map(|x| x.module.clone()))
482        .collect::<BTreeSet<_>>()
483        .into_iter()
484        .collect()
485}
486
487/// The grounded re-prompt: name every reverted file and the modules it fabricated,
488/// then hand the model the authoritative surface and forbid the bad imports. One
489/// message covers **all** reverted files — a single corrective turn, not a storm.
490/// `surface_block` is R1's rendered surface (`FfiManifest::render_block`), the same
491/// authority `knowledge_base` injects; `retry` composes with it but does not
492/// require it (the surface can ride this message alone).
493#[must_use]
494pub fn corrective_prompt(report: &GateReport, surface_block: &str) -> String {
495    let mut out = String::new();
496    out.push_str(
497        "Your previous attempt imported modules this project does not expose, so \
498         those files were reverted. Fix and rewrite them.\n\n",
499    );
500    for f in report.files.iter().filter(|f| !f.is_clean()) {
501        for fab in &f.fabrications {
502            out.push_str(&format!(
503                "- `{}` imported `{}` (line {}), which does not exist.\n",
504                f.path.display(),
505                fab.module,
506                fab.line
507            ));
508        }
509    }
510    if !surface_block.is_empty() {
511        out.push_str("\nThe authoritative import surface is:\n\n");
512        out.push_str(surface_block);
513        out.push('\n');
514    }
515    out.push_str(
516        "\nRewrite the reverted file(s) using only modules from that surface. Do \
517         not import the modules listed above.",
518    );
519    out
520}
521
522/// The authoritative surface the retry loop gates and grounds against — the three
523/// facets of "the project's real import surface", grouped so the loop's signature
524/// stays small.
525pub struct RetrySurface<'a> {
526    /// The module set the gate resolves imports against (R1's `known_modules()`).
527    pub modules: &'a BTreeSet<String>,
528    /// Strictness of the project-surface match (`Exact` default; see [`SurfaceMatch`]).
529    pub mode: SurfaceMatch,
530    /// R1's rendered surface block (`FfiManifest::render_block`), injected into the
531    /// corrective prompt to ground the re-attempt.
532    pub block: &'a str,
533}
534
535/// Is `abs` inside `ws_canon` (the canonicalized workspace root)? Conservative: a
536/// path that cannot be resolved relative to a known root is treated as **outside**.
537/// When the workspace root itself cannot be canonicalized (`None`), the check is
538/// skipped — the ledger fence remains the primary safety — and the path is allowed.
539fn path_within(abs: &Path, ws_canon: Option<&Path>) -> bool {
540    let Some(root) = ws_canon else {
541        return true;
542    };
543    // The file usually exists at revert time; canonicalize it directly when it does.
544    if let Ok(c) = abs.canonicalize() {
545        return c.starts_with(root);
546    }
547    // Otherwise resolve the parent (which must exist) and re-append the file name —
548    // this still defeats a `..`/symlink escape in the parent chain.
549    match (abs.parent(), abs.file_name()) {
550        (Some(parent), Some(name)) => match parent.canonicalize() {
551            Ok(pc) => pc.join(name).starts_with(root),
552            Err(_) => false,
553        },
554        _ => false,
555    }
556}
557
558/// Drive the verify-gated revert-retry loop: revert the gate's flagged set to its
559/// pre-turn state, re-prompt the model with the fabrications named, re-gate, and
560/// repeat up to `max_retries` — accepting as soon as the gate is clean, and giving
561/// up **honestly** (files left reverted, modules reported) at the cap. `max_retries`
562/// is the sole termination authority; a turn runs at most `1 + max_retries` model
563/// calls.
564///
565/// `initial` is the report that already decided retry was needed (from the post-turn
566/// `verify_gate` pass). `ledger` is shared with `rerun` via [`RefCell`] so a file a
567/// retry creates is itself tracked for the next iteration. `surface` re-gates each
568/// attempt and grounds the re-prompt.
569///
570/// # Errors
571/// Propagates a re-gate I/O error or a `rerun` failure (a failed re-attempt aborts
572/// the loop rather than reporting a false accept).
573pub async fn apply_revert_retry(
574    workspace: &Path,
575    surface: &RetrySurface<'_>,
576    ledger: &RefCell<WriteLedger>,
577    initial: GateReport,
578    max_retries: u32,
579    rerun: &mut dyn RetryRerun,
580) -> anyhow::Result<RetryOutcome> {
581    // The workspace boundary, resolved once: nothing outside it is ever written or
582    // removed, even if a ledger path somehow resolves there (defence-in-depth on
583    // top of the ledger fence and the symlink-free walk).
584    let ws_canon = workspace.canonicalize().ok();
585    let mut report = initial;
586    let mut retries_used = 0u32;
587    loop {
588        if report.accept() {
589            return Ok(RetryOutcome::accepted(retries_used));
590        }
591
592        // Revert the flagged set to its pre-turn state. The ledger fences this to
593        // files **newt itself wrote** this turn: a flagged path with no ledger entry
594        // (a pre-existing file, build output, or anything newt did not author) is
595        // skipped — never deleted. A clean file the model wrote the same turn is not
596        // in `revert_set()`, so it is kept. `reverted` records only what was actually
597        // acted on, so the outcome (and the caller's banner) is honest.
598        let mut reverted: Vec<PathBuf> = Vec::new();
599        {
600            let led = ledger.borrow();
601            for rel in report.revert_set() {
602                let abs = workspace.join(rel);
603                if !path_within(&abs, ws_canon.as_deref()) {
604                    tracing::warn!(
605                        path = %rel.display(),
606                        "retry: refusing to revert a path outside the workspace"
607                    );
608                    continue;
609                }
610                match led.revert(&abs)? {
611                    true => reverted.push(rel.to_path_buf()),
612                    false => tracing::warn!(
613                        path = %rel.display(),
614                        "retry: gate-flagged path not in the write ledger — skipping (will not \
615                         delete a file newt did not write)"
616                    ),
617                }
618            }
619        }
620
621        if retries_used >= max_retries {
622            // Honest give-up: the labelled absence (files left reverted) beats a
623            // fabricated presence. The caller surfaces the banner + `retry_exhausted`.
624            return Ok(RetryOutcome {
625                accepted: false,
626                retries_used,
627                reverted,
628                outstanding_modules: outstanding_modules(&report),
629            });
630        }
631
632        let prompt = corrective_prompt(&report, surface.block);
633        rerun.rerun(prompt).await?;
634        retries_used += 1;
635        report = gate_python_workspace_with(workspace, surface.modules, surface.mode)?;
636    }
637}
638
639/// The destructive arm **without** re-prompting: revert the gate's flagged set and
640/// return the outcome, never re-invoking the model. Equivalent to
641/// [`apply_revert_retry`] with `max_retries = 0` — the safety properties (ledger
642/// fence, workspace-boundary guard, honest `reverted` list) are inherited. This is
643/// the live loop's increment-2a path; bumping to a real `RetryRerun` + cap is the
644/// re-prompt increment.
645///
646/// # Errors
647/// Propagates a revert I/O error from [`apply_revert_retry`].
648pub async fn revert_only(
649    workspace: &Path,
650    surface: &RetrySurface<'_>,
651    ledger: &RefCell<WriteLedger>,
652    report: GateReport,
653) -> anyhow::Result<RetryOutcome> {
654    struct NoRerun;
655    #[async_trait(?Send)]
656    impl RetryRerun for NoRerun {
657        async fn rerun(&mut self, _prompt: String) -> anyhow::Result<()> {
658            Ok(())
659        }
660    }
661    apply_revert_retry(workspace, surface, ledger, report, 0, &mut NoRerun).await
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::ffi_manifest::FfiManifest;
668
669    const NEWT_CORE_SRC: &str = r#"
670#[pyclass(name = "Router", module = "newt_agent._newt_agent.core")]
671pub struct PyRouter;
672"#;
673
674    fn surface() -> BTreeSet<String> {
675        FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]).known_modules()
676    }
677
678    #[test]
679    fn clean_file_has_no_fabrications() {
680        // a real submodule import + a stdlib import — both known
681        let v = gate_python_source(
682            "ok.py",
683            "from newt_agent._newt_agent.core import Router\nimport os\nimport os.path\n",
684            &surface(),
685        );
686        assert!(v.is_clean(), "fabrications: {:?}", v.fabrications);
687    }
688
689    #[test]
690    fn fabricated_import_is_flagged_with_line() {
691        let v = gate_python_source("bad.py", "import os\nimport newt_core\n", &surface());
692        assert_eq!(v.fabrications.len(), 1);
693        assert_eq!(v.fabrications[0].module, "newt_core");
694        assert_eq!(v.fabrications[0].line, 2); // points at the offending line
695    }
696
697    #[test]
698    fn report_revert_set_is_only_fabricating_files() {
699        let s = surface();
700        let report = GateReport {
701            files: vec![
702                gate_python_source(
703                    "grounded.py",
704                    "from newt_agent._newt_agent.core import Router\n",
705                    &s,
706                ),
707                gate_python_source("fab.py", "import newt_core\n", &s),
708            ],
709        };
710        assert!(!report.accept());
711        assert_eq!(report.fabrication_count(), 1);
712        let revert = report.revert_set();
713        assert_eq!(revert.len(), 1);
714        assert_eq!(revert[0], Path::new("fab.py"));
715    }
716
717    // ── adversarial regressions (false positives / negatives) ──────────
718
719    #[test]
720    fn relative_imports_are_not_fabrications() {
721        // intra-package; the gate must never revert these (BLOCKER if it does)
722        let v = gate_python_source(
723            "pkg.py",
724            "from . import config\nfrom .helpers import load\nfrom ..util import x\n",
725            &surface(),
726        );
727        assert!(
728            v.is_clean(),
729            "relative imports flagged: {:?}",
730            v.fabrications
731        );
732    }
733
734    #[test]
735    fn future_import_is_not_a_fabrication() {
736        let v = gate_python_source(
737            "typed.py",
738            "from __future__ import annotations\nimport os\n",
739            &surface(),
740        );
741        assert!(v.is_clean(), "__future__ flagged: {:?}", v.fabrications);
742    }
743
744    #[test]
745    fn realistic_clean_file_is_accepted() {
746        // the compound case: future + relative + stdlib + a real PyO3 import
747        let v = gate_python_source(
748            "real.py",
749            "from __future__ import annotations\n\
750             from . import config\n\
751             from .helpers import load\n\
752             import json\n\
753             from newt_agent._newt_agent.core import Router\n",
754            &surface(),
755        );
756        assert!(v.is_clean(), "clean file reverted: {:?}", v.fabrications);
757    }
758
759    // ── the three retry-Goodhart evasions (#357), now caught ───────────
760
761    #[test]
762    fn prefix_breadth_evasion_caught_in_exact_caught_lax_in_prefix() {
763        // `newt_agent._newt_core` is fabricated (real leaf is _newt_agent.core)
764        // but shares the real `newt_agent` root. Exact (default) catches it;
765        // Prefix (the legacy knob) is the documented blind spot.
766        let src = "from newt_agent._newt_core import pyo3_module\n";
767        let exact = gate_python_source(/* default Exact */ "e.py", src, &surface());
768        assert_eq!(exact.fabrications.len(), 1, "Exact must catch it");
769        assert_eq!(exact.fabrications[0].module, "newt_agent._newt_core");
770
771        let lax = gate_python_source_with("p.py", src, &surface(), SurfaceMatch::Prefix);
772        assert!(lax.is_clean(), "Prefix is the documented lax knob");
773    }
774
775    #[test]
776    fn hyphen_fabrication_is_caught() {
777        // `from newt-eval import …` — the hyphen used to escape the regex
778        let v = gate_python_source("h.py", "from newt-eval import pyo3_module\n", &surface());
779        assert_eq!(v.fabrications.len(), 1, "got: {:?}", v.fabrications);
780        assert_eq!(v.fabrications[0].module, "newt-eval");
781    }
782
783    #[test]
784    fn wildcard_fabrication_is_caught() {
785        // `from <fab> import *` — wildcard used to emit zero references
786        let v = gate_python_source("w.py", "from newt_data.pyo3_module import *\n", &surface());
787        assert_eq!(v.fabrications.len(), 1, "got: {:?}", v.fabrications);
788        assert_eq!(v.fabrications[0].module, "newt_data.pyo3_module");
789    }
790
791    #[test]
792    fn grounded_wildcard_is_clean() {
793        // a wildcard of a REAL module must still pass
794        let v = gate_python_source(
795            "gw.py",
796            "from newt_agent._newt_agent.core import *\n",
797            &surface(),
798        );
799        assert!(
800            v.is_clean(),
801            "grounded wildcard flagged: {:?}",
802            v.fabrications
803        );
804    }
805
806    #[test]
807    fn multiline_paren_fabricated_module_is_caught() {
808        // the black/isort open-paren form must not slip past the gate
809        let v = gate_python_source(
810            "evade.py",
811            "from newt_db import (\n    Alpha,\n    Beta,\n)\n",
812            &surface(),
813        );
814        assert_eq!(v.fabrications.len(), 1, "got: {:?}", v.fabrications);
815        assert_eq!(v.fabrications[0].module, "newt_db");
816    }
817
818    #[test]
819    fn one_fabricated_module_many_symbols_counts_once() {
820        // single-line parenthesized import of a fabricated module → one fabrication
821        let v = gate_python_source(
822            "multi.py",
823            "from newt_db import (Alpha, Beta, Gamma)\n",
824            &surface(),
825        );
826        assert_eq!(v.fabrications.len(), 1, "got: {:?}", v.fabrications);
827    }
828
829    #[test]
830    fn gate_workspace_walks_and_is_relative() {
831        let tmp = tempfile::tempdir().unwrap();
832        std::fs::create_dir_all(tmp.path().join("examples")).unwrap();
833        std::fs::write(
834            tmp.path().join("examples/grounded.py"),
835            "from newt_agent._newt_agent.core import Router\nimport json\n",
836        )
837        .unwrap();
838        std::fs::write(tmp.path().join("examples/fab.py"), "import newt_coder\n").unwrap();
839
840        let report = gate_python_workspace(tmp.path(), &surface()).unwrap();
841        assert_eq!(report.files.len(), 2);
842        assert!(!report.accept());
843        assert_eq!(report.revert_set(), vec![Path::new("examples/fab.py")]);
844    }
845
846    // ── the `retry` mechanism ───────────────────────────────────────────────
847
848    #[test]
849    fn ledger_restores_an_edited_file() {
850        let tmp = tempfile::tempdir().unwrap();
851        let f = tmp.path().join("edit.py");
852        std::fs::write(&f, "original\n").unwrap();
853        let mut led = WriteLedger::new();
854        led.note_before_write(&f); // captures "original"
855        std::fs::write(&f, "fabricated\n").unwrap();
856        assert!(led.revert(&f).unwrap());
857        assert_eq!(std::fs::read_to_string(&f).unwrap(), "original\n");
858    }
859
860    #[test]
861    fn ledger_deletes_a_newly_created_file() {
862        let tmp = tempfile::tempdir().unwrap();
863        let f = tmp.path().join("new.py");
864        let mut led = WriteLedger::new();
865        led.note_before_write(&f); // file absent → records None
866        std::fs::write(&f, "import newt_core\n").unwrap();
867        assert!(led.revert(&f).unwrap());
868        assert!(!f.exists(), "a file that did not exist pre-turn is removed");
869    }
870
871    #[test]
872    fn ledger_first_write_wins_across_a_turn() {
873        let tmp = tempfile::tempdir().unwrap();
874        let f = tmp.path().join("multi.py");
875        std::fs::write(&f, "pre-turn\n").unwrap();
876        let mut led = WriteLedger::new();
877        led.note_before_write(&f); // "pre-turn"
878        std::fs::write(&f, "intermediate\n").unwrap();
879        led.note_before_write(&f); // no-op — pre-turn state preserved
880        std::fs::write(&f, "final\n").unwrap();
881        led.revert(&f).unwrap();
882        assert_eq!(
883            std::fs::read_to_string(&f).unwrap(),
884            "pre-turn\n",
885            "revert restores the pre-turn state, not an intermediate write"
886        );
887        assert_eq!(led.len(), 1);
888    }
889
890    #[test]
891    fn ledger_revert_reports_untracked_paths() {
892        let tmp = tempfile::tempdir().unwrap();
893        let f = tmp.path().join("untracked.py");
894        std::fs::write(&f, "x\n").unwrap();
895        let led = WriteLedger::new();
896        assert!(!led.revert(&f).unwrap(), "no entry → false, no delete");
897        assert!(f.exists(), "an untracked file is never silently removed");
898    }
899
900    #[test]
901    fn note_before_write_leaves_an_unreadable_path_untracked() {
902        // A directory at the path makes `std::fs::read` fail with a non-NotFound
903        // error. That must NOT be recorded as the absent-marker `Some(None)` (which
904        // would delete on revert) — the path is left untracked instead.
905        let tmp = tempfile::tempdir().unwrap();
906        let p = tmp.path().join("not_a_file");
907        std::fs::create_dir(&p).unwrap();
908        let mut led = WriteLedger::new();
909        led.note_before_write(&p);
910        assert_eq!(led.len(), 0, "an unreadable path is not recorded");
911        assert!(!led.revert(&p).unwrap(), "untracked ⇒ revert is a no-op");
912        assert!(
913            p.exists(),
914            "revert must never remove an unreadable pre-existing path"
915        );
916    }
917
918    #[test]
919    fn corrective_prompt_names_files_modules_and_surface() {
920        let report = GateReport {
921            files: vec![FileVerdict {
922                path: "examples/bad.py".into(),
923                fabrications: vec![Fabrication {
924                    module: "newt_core".into(),
925                    line: 3,
926                }],
927            }],
928        };
929        let p = corrective_prompt(&report, "SURFACE-BLOCK-HERE");
930        assert!(p.contains("examples/bad.py"));
931        assert!(p.contains("newt_core"));
932        assert!(p.contains("line 3"));
933        assert!(p.contains("SURFACE-BLOCK-HERE"));
934        assert!(p.contains("Do not import"));
935    }
936
937    /// A scripted re-run that mimics the live tool seam: each call writes the next
938    /// queued content to `file`, recording the write in the shared ledger first.
939    struct ScriptedRerun<'a> {
940        workspace: PathBuf,
941        file: PathBuf,
942        ledger: &'a RefCell<WriteLedger>,
943        contents: std::collections::VecDeque<String>,
944        calls: usize,
945    }
946
947    #[async_trait(?Send)]
948    impl RetryRerun for ScriptedRerun<'_> {
949        async fn rerun(&mut self, _prompt: String) -> anyhow::Result<()> {
950            self.calls += 1;
951            let content = self.contents.pop_front().unwrap_or_default();
952            let abs = self.workspace.join(&self.file);
953            self.ledger.borrow_mut().note_before_write(&abs);
954            std::fs::write(&abs, content)?;
955            Ok(())
956        }
957    }
958
959    /// Seed a one-file fabricating turn: record the pre-turn (absent) state, write
960    /// the bad file, and return the initial gate report.
961    fn seed_fabricating_turn(ws: &Path, file: &str, ledger: &RefCell<WriteLedger>) -> GateReport {
962        let abs = ws.join(file);
963        ledger.borrow_mut().note_before_write(&abs);
964        std::fs::write(&abs, "import newt_core\n").unwrap();
965        gate_python_workspace(ws, &surface()).unwrap()
966    }
967
968    #[tokio::test]
969    async fn retry_accepts_when_a_reattempt_grounds_the_file() {
970        let tmp = tempfile::tempdir().unwrap();
971        let ledger = RefCell::new(WriteLedger::new());
972        let initial = seed_fabricating_turn(tmp.path(), "bad.py", &ledger);
973        assert!(!initial.accept());
974
975        let mut rerun = ScriptedRerun {
976            workspace: tmp.path().to_path_buf(),
977            file: "bad.py".into(),
978            ledger: &ledger,
979            contents: ["from newt_agent._newt_agent.core import Router\n".to_string()].into(),
980            calls: 0,
981        };
982        let surf = surface();
983        let outcome = apply_revert_retry(
984            tmp.path(),
985            &RetrySurface {
986                modules: &surf,
987                mode: SurfaceMatch::Exact,
988                block: "SURFACE",
989            },
990            &ledger,
991            initial,
992            2,
993            &mut rerun,
994        )
995        .await
996        .unwrap();
997
998        assert!(outcome.accepted);
999        assert_eq!(outcome.retries_used, 1);
1000        assert_eq!(rerun.calls, 1);
1001        assert!(outcome.reverted.is_empty());
1002        assert_eq!(
1003            std::fs::read_to_string(tmp.path().join("bad.py")).unwrap(),
1004            "from newt_agent._newt_agent.core import Router\n"
1005        );
1006    }
1007
1008    #[tokio::test]
1009    async fn retry_gives_up_honestly_at_the_cap() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let ledger = RefCell::new(WriteLedger::new());
1012        let initial = seed_fabricating_turn(tmp.path(), "bad.py", &ledger);
1013
1014        // Every re-attempt keeps fabricating → never grounds.
1015        let mut rerun = ScriptedRerun {
1016            workspace: tmp.path().to_path_buf(),
1017            file: "bad.py".into(),
1018            ledger: &ledger,
1019            contents: ["import newt_core\n".into(), "import newt_core\n".into()].into(),
1020            calls: 0,
1021        };
1022        let surf = surface();
1023        let outcome = apply_revert_retry(
1024            tmp.path(),
1025            &RetrySurface {
1026                modules: &surf,
1027                mode: SurfaceMatch::Exact,
1028                block: "SURFACE",
1029            },
1030            &ledger,
1031            initial,
1032            2,
1033            &mut rerun,
1034        )
1035        .await
1036        .unwrap();
1037
1038        assert!(!outcome.accepted);
1039        assert_eq!(
1040            outcome.retries_used, 2,
1041            "ran exactly 1 + max_retries calls' worth"
1042        );
1043        assert_eq!(rerun.calls, 2);
1044        assert_eq!(outcome.reverted, vec![PathBuf::from("bad.py")]);
1045        assert_eq!(outcome.outstanding_modules, vec!["newt_core".to_string()]);
1046        assert!(
1047            !tmp.path().join("bad.py").exists(),
1048            "give-up leaves the file reverted, not the last fabrication"
1049        );
1050    }
1051
1052    #[tokio::test]
1053    async fn retry_is_a_no_op_when_the_initial_report_is_clean() {
1054        let tmp = tempfile::tempdir().unwrap();
1055        let ledger = RefCell::new(WriteLedger::new());
1056
1057        struct NeverRerun;
1058        #[async_trait(?Send)]
1059        impl RetryRerun for NeverRerun {
1060            async fn rerun(&mut self, _p: String) -> anyhow::Result<()> {
1061                panic!("rerun must not be called when the gate already accepts");
1062            }
1063        }
1064
1065        let clean = GateReport {
1066            files: vec![FileVerdict {
1067                path: "ok.py".into(),
1068                fabrications: vec![],
1069            }],
1070        };
1071        let surf = surface();
1072        let outcome = apply_revert_retry(
1073            tmp.path(),
1074            &RetrySurface {
1075                modules: &surf,
1076                mode: SurfaceMatch::Exact,
1077                block: "SURFACE",
1078            },
1079            &ledger,
1080            clean,
1081            2,
1082            &mut NeverRerun,
1083        )
1084        .await
1085        .unwrap();
1086        assert!(outcome.accepted);
1087        assert_eq!(outcome.retries_used, 0);
1088    }
1089
1090    #[tokio::test]
1091    async fn revert_only_touches_only_ledgered_files() {
1092        let tmp = tempfile::tempdir().unwrap();
1093        // newt creates one fabricating file (recorded in the ledger)...
1094        let ledger = RefCell::new(WriteLedger::new());
1095        let mine = tmp.path().join("mine.py");
1096        ledger.borrow_mut().note_before_write(&mine);
1097        std::fs::write(&mine, "import newt_core\n").unwrap();
1098        // ...and a fabricating file newt never wrote exists too (NOT in the ledger).
1099        let theirs = tmp.path().join("theirs.py");
1100        std::fs::write(&theirs, "import newt_core\n").unwrap();
1101
1102        let surf = surface();
1103        let report = gate_python_workspace(tmp.path(), &surf).unwrap();
1104        assert_eq!(report.revert_set().len(), 2, "both files fabricate");
1105
1106        let outcome = revert_only(
1107            tmp.path(),
1108            &RetrySurface {
1109                modules: &surf,
1110                mode: SurfaceMatch::Exact,
1111                block: "",
1112            },
1113            &ledger,
1114            report,
1115        )
1116        .await
1117        .unwrap();
1118
1119        // Only newt's file is reverted (deleted); the other is left exactly as-is.
1120        assert_eq!(outcome.reverted, vec![PathBuf::from("mine.py")]);
1121        assert!(!mine.exists(), "newt's created fabrication is removed");
1122        assert_eq!(
1123            std::fs::read_to_string(&theirs).unwrap(),
1124            "import newt_core\n",
1125            "a file newt did not write is never touched"
1126        );
1127    }
1128
1129    #[cfg(unix)]
1130    #[test]
1131    fn gate_does_not_follow_symlinked_dirs() {
1132        // A symlinked directory pointing OUTSIDE the workspace must not be walked —
1133        // otherwise external `.py` enter the gate's blast radius.
1134        let outside = tempfile::tempdir().unwrap();
1135        std::fs::write(outside.path().join("external.py"), "import newt_core\n").unwrap();
1136        let ws = tempfile::tempdir().unwrap();
1137        std::fs::write(ws.path().join("own.py"), "import newt_core\n").unwrap();
1138        std::os::unix::fs::symlink(outside.path(), ws.path().join("linked")).unwrap();
1139
1140        let report = gate_python_workspace(ws.path(), &surface()).unwrap();
1141        let paths: Vec<_> = report.files.iter().map(|f| f.path.clone()).collect();
1142        assert_eq!(
1143            paths,
1144            vec![PathBuf::from("own.py")],
1145            "only the workspace's own .py is gated; the symlinked external file is skipped"
1146        );
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tier_tests {
1152    use super::*;
1153
1154    fn dirty() -> GateReport {
1155        GateReport {
1156            files: vec![FileVerdict {
1157                path: PathBuf::from("fab.py"),
1158                fabrications: vec![Fabrication {
1159                    module: "newt_core".to_string(),
1160                    line: 1,
1161                }],
1162            }],
1163        }
1164    }
1165
1166    #[test]
1167    fn clean_report_passes_every_tier() {
1168        let clean = GateReport::default();
1169        for t in [
1170            VerifyTier::Off,
1171            VerifyTier::Advisory,
1172            VerifyTier::RevertOnce,
1173            VerifyTier::RevertRetry,
1174        ] {
1175            assert_eq!(t.action(&clean), VerifyAction::Pass);
1176        }
1177    }
1178
1179    #[test]
1180    fn dirty_report_maps_per_tier() {
1181        let d = dirty();
1182        assert_eq!(VerifyTier::Off.action(&d), VerifyAction::Pass);
1183        assert_eq!(VerifyTier::Advisory.action(&d), VerifyAction::Warn);
1184        assert_eq!(VerifyTier::RevertOnce.action(&d), VerifyAction::Revert);
1185        assert_eq!(
1186            VerifyTier::RevertRetry.action(&d),
1187            VerifyAction::RevertAndRetry
1188        );
1189    }
1190
1191    #[test]
1192    fn default_tier_is_revert_retry() {
1193        assert_eq!(VerifyTier::default(), VerifyTier::RevertRetry);
1194        let t: VerifyTier = serde_json::from_str("\"advisory\"").unwrap();
1195        assert_eq!(t, VerifyTier::Advisory);
1196    }
1197
1198    #[test]
1199    fn banner_is_none_on_clean_finish() {
1200        assert!(turn_verdict_banner(&[], false, false).is_none());
1201    }
1202
1203    #[test]
1204    fn banner_names_reverts_only() {
1205        let b = turn_verdict_banner(&["a.py".to_string()], false, false).unwrap();
1206        assert!(b.contains("reverted 1 file") && b.contains("a.py"));
1207        assert!(!b.contains("tool-round cap"));
1208        assert!(b.contains("needs human review"));
1209    }
1210
1211    #[test]
1212    fn banner_names_cap_only() {
1213        let b = turn_verdict_banner(&[], false, true).unwrap();
1214        assert!(b.contains("tool-round cap"));
1215        assert!(!b.contains("reverted"));
1216    }
1217
1218    #[test]
1219    fn banner_names_both_in_one_line() {
1220        let b = turn_verdict_banner(&["a.py".to_string(), "b.py".to_string()], true, true).unwrap();
1221        assert!(b.contains("reverted 2 file"));
1222        assert!(b.contains("exhausting retries"));
1223        assert!(b.contains("tool-round cap"));
1224        assert_eq!(b.lines().count(), 1, "one honest line");
1225    }
1226}