1use 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#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Fabrication {
35 pub module: String,
37 pub line: usize,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct FileVerdict {
44 pub path: PathBuf,
46 pub fabrications: Vec<Fabrication>,
48}
49
50impl FileVerdict {
51 #[must_use]
53 pub fn is_clean(&self) -> bool {
54 self.fabrications.is_empty()
55 }
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct GateReport {
61 pub files: Vec<FileVerdict>,
63}
64
65impl GateReport {
66 #[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 #[must_use]
79 pub fn accept(&self) -> bool {
80 self.files.iter().all(FileVerdict::is_clean)
81 }
82
83 #[must_use]
85 pub fn fabrication_count(&self) -> usize {
86 self.files.iter().map(|f| f.fabrications.len()).sum()
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum SurfaceMatch {
103 #[default]
105 Exact,
106 Prefix,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum VerifyTier {
116 Off,
118 Advisory,
120 RevertOnce,
122 #[default]
124 RevertRetry,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum VerifyAction {
131 Pass,
133 Warn,
135 Revert,
137 RevertAndRetry,
139}
140
141impl VerifyTier {
142 #[must_use]
144 pub fn action(self, report: &GateReport) -> VerifyAction {
145 if report.accept() {
146 return VerifyAction::Pass; }
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#[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
189fn 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#[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#[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
226fn 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 fabrications.dedup_by(|a, b| a.module == b.module && a.line == b.line);
247 FileVerdict {
248 path: path.into(),
249 fabrications,
250 }
251}
252
253pub 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
266pub 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
289const 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
306fn 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 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#[derive(Debug, Default, Clone)]
361pub struct WriteLedger {
362 entries: BTreeMap<PathBuf, Option<Vec<u8>>>,
364}
365
366impl WriteLedger {
367 #[must_use]
369 pub fn new() -> Self {
370 Self::default()
371 }
372
373 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, };
393 self.entries.insert(path, prior);
394 }
395
396 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 #[must_use]
421 pub fn is_empty(&self) -> bool {
422 self.entries.is_empty()
423 }
424
425 #[must_use]
427 pub fn len(&self) -> usize {
428 self.entries.len()
429 }
430}
431
432#[async_trait(?Send)]
441pub trait RetryRerun {
442 async fn rerun(&mut self, corrective_prompt: String) -> anyhow::Result<()>;
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct RetryOutcome {
453 pub accepted: bool,
455 pub retries_used: u32,
457 pub reverted: Vec<PathBuf>,
460 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
476fn 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#[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
522pub struct RetrySurface<'a> {
526 pub modules: &'a BTreeSet<String>,
528 pub mode: SurfaceMatch,
530 pub block: &'a str,
533}
534
535fn path_within(abs: &Path, ws_canon: Option<&Path>) -> bool {
540 let Some(root) = ws_canon else {
541 return true;
542 };
543 if let Ok(c) = abs.canonicalize() {
545 return c.starts_with(root);
546 }
547 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
558pub 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 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 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 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
639pub 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 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); }
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 #[test]
720 fn relative_imports_are_not_fabrications() {
721 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 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 #[test]
762 fn prefix_breadth_evasion_caught_in_exact_caught_lax_in_prefix() {
763 let src = "from newt_agent._newt_core import pyo3_module\n";
767 let exact = gate_python_source("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 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 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 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 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 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 #[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); 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); 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); std::fs::write(&f, "intermediate\n").unwrap();
879 led.note_before_write(&f); 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 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 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 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 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 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 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 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 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}