1use std::collections::{BTreeMap, BTreeSet};
7use std::path::Path;
8
9use regex::Regex;
10use sha2::{Digest, Sha256};
11
12use crate::tools::paths::{Class, Protected};
13use crate::tools::policy::Policy;
14use crate::worktree::{Change, ChangeKind};
15
16const SKIPPED_DIRS: &[&str] = &[
19 ".git",
20 "target",
21 "node_modules",
22 ".venv",
23 "venv",
24 "__pycache__",
25 ".tox",
26 ".mypy_cache",
27 ".pytest_cache",
28];
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct JudgeSnapshot(BTreeMap<String, String>);
35
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct Drift {
39 pub path: String,
41 pub class: &'static str,
43 pub change: &'static str,
45}
46
47impl JudgeSnapshot {
48 #[must_use]
51 pub fn take(root: &Path, policy: &Policy) -> Self {
52 let mut files = BTreeMap::new();
53 let walk = ignore::WalkBuilder::new(root)
54 .hidden(false)
55 .ignore(false)
56 .git_ignore(false)
57 .git_global(false)
58 .git_exclude(false)
59 .parents(false)
60 .follow_links(false)
61 .filter_entry(|e| {
62 !(e.depth() > 0
63 && e.file_type().is_some_and(|t| t.is_dir())
64 && SKIPPED_DIRS.contains(&e.file_name().to_string_lossy().as_ref()))
65 })
66 .build();
67 for entry in walk.flatten() {
68 if !entry.file_type().is_some_and(|t| t.is_file()) {
69 continue;
70 }
71 let Ok(rel) = entry.path().strip_prefix(root) else {
72 continue;
73 };
74 let rel = rel.to_string_lossy().replace('\\', "/");
75 if class_of(policy, &rel).is_none() {
76 continue;
77 }
78 if let Ok(bytes) = std::fs::read(entry.path()) {
79 files.insert(rel, sha256_hex(&bytes));
80 }
81 }
82 Self(files)
83 }
84
85 #[must_use]
87 pub fn entries(&self) -> &BTreeMap<String, String> {
88 &self.0
89 }
90
91 #[must_use]
93 pub fn from_entries(entries: BTreeMap<String, String>) -> Self {
94 Self(entries)
95 }
96
97 pub fn set(&mut self, path: &str, hash: String) {
99 self.0.insert(path.to_owned(), hash);
100 }
101
102 #[must_use]
105 pub fn drift(&self, later: &Self, policy: &Policy, approved: &BTreeSet<String>) -> Vec<Drift> {
106 let mut out = Vec::new();
107 let paths: BTreeSet<&String> = self.0.keys().chain(later.0.keys()).collect();
108 for path in paths {
109 let change = match (self.0.get(path), later.0.get(path)) {
110 (None, Some(_)) => "added",
111 (Some(_), None) => "deleted",
112 (Some(a), Some(b)) if a != b => "modified",
113 _ => continue,
114 };
115 if approved.contains(path.as_str()) {
116 continue;
117 }
118 if let Some(class) = class_of(policy, path) {
119 out.push(Drift {
120 path: path.clone(),
121 class,
122 change,
123 });
124 }
125 }
126 out
127 }
128}
129
130fn class_of(policy: &Policy, rel: &str) -> Option<&'static str> {
131 match policy.write_protected(rel) {
132 Some(Class::Judge) => Some("judge"),
133 Some(Class::Intent) => Some("intent"),
134 Some(_) => None,
135 None if policy.protected().is_context(rel) => Some("context"),
136 None => None,
137 }
138}
139
140const TEST_MARKERS: &str = r"#\[(tokio::)?test\b|^\s*def test_|^\s*(async\s+)?fn test_|\b(it|test)\s*\(\s*['\x22`]|@Test\b|^\s*func Test";
142const SKIP_MARKERS: &str = r"#\[ignore\b|@pytest\.mark\.(skip|xfail)|\b(?:it|describe|test|suite|pytest)\.skip\s*\(|\bx(it|describe|test)\s*\(|@Disabled\b|@Ignore\b|\bt\.Skip(Now)?\s*\(|@unittest\.skip|\b(?:it|test)\.todo\s*\(";
144
145const TEST_NAMES: &str = r#"(?m)#\[(?:tokio::)?test\b[^\]]*\]\s*(?:#\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)|^\s*(?:async\s+)?def\s+(test_\w+)|^\s*func\s+(Test\w+)|\b(?:it|test)\s*\(\s*['"`]([^'"`]+)['"`]"#;
148
149#[must_use]
151pub fn removed_tests(before: &str, after: &str) -> Vec<String> {
152 let Ok(re) = Regex::new(TEST_NAMES) else {
153 return Vec::new();
154 };
155 let names = |text: &str| -> Vec<String> {
156 re.captures_iter(text)
157 .filter_map(|c| {
158 c.iter()
159 .skip(1)
160 .flatten()
161 .next()
162 .map(|m| m.as_str().to_owned())
163 })
164 .collect()
165 };
166 let kept: BTreeSet<String> = names(after).into_iter().collect();
167 let mut gone = names(before);
168 gone.retain(|n| !kept.contains(n));
169 gone.dedup();
170 gone
171}
172
173const PLACEHOLDER_MARKERS: &str = r#"\braise\s+NotImplementedError\b|\btodo!\s*\(|\bunimplemented!\s*\(|(?i:throw\s+new\s+Error\s*\(\s*['"`](?:not implemented|todo|placeholder))|(?i:\b(?:t\.Fatal|pytest\.fail|fail)\s*\(\s*['"`](?:not implemented|todo|placeholder))"#;
176
177#[must_use]
181pub fn has_placeholder(text: &str) -> bool {
182 Regex::new(PLACEHOLDER_MARKERS).is_ok_and(|r| r.is_match(text))
183}
184
185#[must_use]
189pub fn asserts_crlf_as_text(path: &str, text: &str) -> bool {
190 path.ends_with(".py")
191 && (text.contains("text=True") || text.contains("universal_newlines=True"))
192 && text
193 .lines()
194 .any(|l| l.contains("assert") && l.contains("\\r\\n"))
195}
196
197#[must_use]
202pub fn invalid_escape(output: &str, paths: &[&str]) -> Option<String> {
203 output.lines().map(str::trim).map(str::to_owned).find(|l| {
204 l.contains("SyntaxWarning")
205 && l.contains("escape sequence")
206 && paths
207 .iter()
208 .any(|p| l.contains(p.rsplit('/').next().unwrap_or(p)))
209 })
210}
211
212#[must_use]
215pub fn never_passes(path: &str, text: &str) -> Option<&'static str> {
216 if asserts_crlf_as_text(path, text) {
217 Some("it asserts \\r\\n in output read with text=True, which turns it into \\n")
218 } else if has_placeholder(text) {
219 Some("it raises \"not implemented\" instead of checking the behaviour")
220 } else {
221 None
222 }
223}
224
225#[must_use]
227pub fn has_skip_marker(text: &str) -> bool {
228 Regex::new(SKIP_MARKERS).is_ok_and(|r| r.is_match(text))
229}
230
231#[must_use]
234pub fn only_additions(old: &str, new: &str) -> bool {
235 let mut new_lines = new.lines();
236 old.lines().all(|line| new_lines.any(|n| n == line))
237}
238
239#[must_use]
245pub fn audit(
246 changes: &[Change],
247 patch: &str,
248 frozen: &BTreeSet<String>,
249 additions_ok: bool,
250 approved: &BTreeSet<String>,
251) -> Vec<String> {
252 audit_in(
253 &Protected::default(),
254 changes,
255 patch,
256 frozen,
257 additions_ok,
258 approved,
259 )
260}
261
262#[must_use]
264pub fn audit_in(
265 protected: &Protected,
266 changes: &[Change],
267 patch: &str,
268 frozen: &BTreeSet<String>,
269 additions_ok: bool,
270 approved: &BTreeSet<String>,
271) -> Vec<String> {
272 let mut findings = Vec::new();
273 let removed_lines = removed_lines(patch);
274
275 for c in changes {
276 if frozen
277 .iter()
278 .any(|f| f.to_lowercase() == c.path.to_lowercase())
279 {
280 match c.kind {
281 ChangeKind::Deleted => findings.push(format!("frozen test deleted: {}", c.path)),
282 ChangeKind::Modified
283 if additions_ok && removed_lines.get(&c.path).copied().unwrap_or(0) == 0 => {}
284 ChangeKind::Modified => findings.push(format!("frozen test modified: {}", c.path)),
285 ChangeKind::Added => {}
286 }
287 }
288 match protected.write_class(&c.path) {
289 Some(Class::Judge) => findings.push(format!("verification input changed: {}", c.path)),
290 Some(Class::Intent) => findings.push(format!("spec changed: {}", c.path)),
291 None if protected.is_context(&c.path) && !approved.contains(&c.path) => {
292 findings.push(format!("agent context file changed: {}", c.path));
293 }
294 _ => {}
295 }
296 }
297
298 let (Ok(test), Ok(skip)) = (Regex::new(TEST_MARKERS), Regex::new(SKIP_MARKERS)) else {
299 return findings;
300 };
301 let mut tests: BTreeMap<String, (usize, usize)> = BTreeMap::new(); let mut text: BTreeMap<String, (String, String)> = BTreeMap::new(); let mut skips: BTreeSet<String> = BTreeSet::new();
304 let mut file = String::new();
305 for line in patch.lines() {
306 if let Some(header) = line.strip_prefix("diff --git a/") {
307 file = header
310 .split_once(" b/")
311 .map_or(header, |(a, _)| a)
312 .to_owned();
313 } else if line.starts_with("+++ ") || line.starts_with("--- ") {
314 } else if let Some(added) = line.strip_prefix('+') {
316 let t = text.entry(file.clone()).or_default();
317 t.0.push_str(added);
318 t.0.push('\n');
319 if test.is_match(added) {
320 tests.entry(file.clone()).or_default().0 += 1;
321 }
322 if skip.is_match(added) {
323 skips.insert(file.clone());
324 }
325 } else if let Some(removed) = line.strip_prefix('-') {
326 let t = text.entry(file.clone()).or_default();
327 t.1.push_str(removed);
328 t.1.push('\n');
329 if test.is_match(removed) {
330 tests.entry(file.clone()).or_default().1 += 1;
331 }
332 }
333 }
334 let deleted: BTreeSet<&str> = changes
335 .iter()
336 .filter(|c| c.kind == ChangeKind::Deleted)
337 .map(|c| c.path.as_str())
338 .collect();
339 for (path, (added, removed)) in tests {
340 if removed > added && !deleted.contains(path.as_str()) {
342 let names = text
343 .get(&path)
344 .map(|(a, r)| removed_tests(r, a))
345 .unwrap_or_default();
346 let named = if names.is_empty() {
347 String::new()
348 } else {
349 format!(": {}", names.join(", "))
350 };
351 findings.push(format!(
352 "test removed in {path} ({removed} removed, {added} added){named}"
353 ));
354 }
355 }
356 for path in skips {
357 findings.push(format!("skip marker added in {path}"));
358 }
359 findings
360}
361
362fn removed_lines(patch: &str) -> BTreeMap<String, usize> {
364 let mut out: BTreeMap<String, usize> = BTreeMap::new();
365 let mut file = String::new();
366 for line in patch.lines() {
367 if let Some(header) = line.strip_prefix("diff --git a/") {
368 file = header
369 .split_once(" b/")
370 .map_or(header, |(a, _)| a)
371 .to_owned();
372 } else if line.starts_with('-') && !line.starts_with("--- ") {
373 *out.entry(file.clone()).or_default() += 1;
374 }
375 }
376 out
377}
378
379#[must_use]
382pub fn sha256_hex(bytes: &[u8]) -> String {
383 Sha256::digest(bytes)
384 .iter()
385 .map(|b| format!("{b:02x}"))
386 .collect()
387}
388
389pub(crate) fn untick(bytes: &[u8]) -> Vec<u8> {
391 let mut out = bytes.to_vec();
392 for i in 1..out.len().saturating_sub(1) {
393 if out[i - 1] == b'[' && out[i + 1] == b']' && matches!(out[i], b'x' | b'X') {
394 out[i] = b' ';
395 }
396 }
397 out
398}
399
400#[cfg(test)]
401mod tests {
402
403 use super::*;
404
405 fn change(path: &str, kind: ChangeKind) -> Change {
406 Change {
407 path: path.to_owned(),
408 kind,
409 }
410 }
411
412 #[test]
413 fn removed_tests_are_named_across_languages() {
414 let before = "#[cfg(test)]\nmod tests {\n #[test]\n fn it_works() {}\n #[tokio::test]\n async fn later() {}\n}\ndef test_py(): pass\nfunc TestGo(t *testing.T) {}\nit('greets', () => {})\n";
415 assert_eq!(
416 removed_tests(before, "#[test]\nfn later() {}\n"),
417 ["it_works", "test_py", "TestGo", "greets"]
418 );
419 assert!(removed_tests(before, before).is_empty());
420 }
421
422 #[test]
423 fn flags_tampering_and_passes_clean_diffs() {
424 let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
425 let patch = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n- #[test]\n+ #[ignore]\n+ #[test]\n";
426 let f = audit(
427 &[
428 change("tests/api.rs", ChangeKind::Deleted),
429 change("speccoder.toml", ChangeKind::Modified),
430 ],
431 patch,
432 &frozen,
433 false,
434 &BTreeSet::new(),
435 );
436 assert_eq!(
437 f,
438 [
439 "frozen test deleted: tests/api.rs",
440 "verification input changed: speccoder.toml",
441 "skip marker added in src/lib.rs",
442 ]
443 );
444
445 let removed = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -5,4 +4,0 @@\n- #[test]\n- fn a() {}\n";
446 assert_eq!(
447 audit(&[], removed, &BTreeSet::new(), false, &BTreeSet::new()),
448 ["test removed in src/lib.rs (1 removed, 0 added): a"]
449 );
450
451 let clean = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1,4 @@\n+pub fn add() {}\n+ #[test]\n+ fn adds() {}\n";
452 assert!(
453 audit(
454 &[
455 change("src/lib.rs", ChangeKind::Modified),
456 change("tests/new.rs", ChangeKind::Added)
457 ],
458 clean,
459 &frozen,
460 false,
461 &BTreeSet::new()
462 )
463 .is_empty()
464 );
465 }
466
467 #[test]
468 fn additions_to_a_frozen_test_pass_only_in_additions_mode() {
469 let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
470 let added = "diff --git a/tests/api.rs b/tests/api.rs\n--- a/tests/api.rs\n+++ b/tests/api.rs\n@@ -1,2 +1,4 @@\n #[test]\n fn a() {}\n+#[test]\n+fn b() {}\n";
471 let changed = "diff --git a/tests/api.rs b/tests/api.rs\n--- a/tests/api.rs\n+++ b/tests/api.rs\n@@ -1,2 +1,2 @@\n #[test]\n-fn a() { assert!(x()) }\n+fn a() {}\n";
472 let modified = [change("tests/api.rs", ChangeKind::Modified)];
473 assert!(audit(&modified, added, &frozen, true, &BTreeSet::new()).is_empty());
474 assert_eq!(
475 audit(&modified, added, &frozen, false, &BTreeSet::new()),
476 ["frozen test modified: tests/api.rs"]
477 );
478 assert_eq!(
479 audit(&modified, changed, &frozen, true, &BTreeSet::new()),
480 ["frozen test modified: tests/api.rs"]
481 );
482
483 assert!(only_additions("a\nb\n", "a\nnew\nb\nmore\n"));
484 assert!(!only_additions("a\nb\n", "a\nB\n"));
485 assert!(!only_additions("a\nb\n", "b\na\n"));
486 }
487
488 #[test]
489 fn a_snapshot_sees_ignored_judge_inputs_and_reports_drift() {
490 use crate::tools::policy::Policy;
491 let dir = tempfile::tempdir().unwrap();
492 let root = dir.path();
493 std::fs::create_dir_all(root.join(".claude")).unwrap();
494 std::fs::create_dir_all(root.join("target/debug")).unwrap();
495 std::fs::write(root.join(".claude/settings.json"), "{}").unwrap();
496 std::fs::write(root.join("AGENTS.md"), "a").unwrap();
497 std::fs::write(root.join("speccoder.toml"), "x").unwrap();
498 std::fs::write(root.join("src.rs"), "free").unwrap();
499 std::fs::write(root.join("target/debug/speccoder.toml"), "skipped").unwrap();
500 let policy = Policy::new(&[], &[]);
501 let before = JudgeSnapshot::take(root, &policy);
502 assert_eq!(
503 before.0.len(),
504 3,
505 "{:?}",
506 before.0.keys().collect::<Vec<_>>()
507 );
508
509 std::fs::write(root.join(".claude/settings.local.json"), "{}").unwrap();
510 std::fs::write(root.join("AGENTS.md"), "b").unwrap();
511 std::fs::remove_file(root.join("speccoder.toml")).unwrap();
512 std::fs::write(root.join("src.rs"), "changed").unwrap();
513 let after = JudgeSnapshot::take(root, &policy);
514 let drift = before.drift(&after, &policy, &BTreeSet::new());
515 let seen: Vec<(&str, &str, &str)> = drift
516 .iter()
517 .map(|d| (d.path.as_str(), d.class, d.change))
518 .collect();
519 assert_eq!(
520 seen,
521 [
522 (".claude/settings.local.json", "judge", "added"),
523 ("AGENTS.md", "context", "modified"),
524 ("speccoder.toml", "judge", "deleted"),
525 ]
526 );
527 let approved: BTreeSet<String> = ["AGENTS.md".to_owned()].into();
529 assert_eq!(before.drift(&after, &policy, &approved).len(), 2);
530 std::fs::write(root.join("AGENTS.md"), "a").unwrap();
532 std::fs::write(root.join("speccoder.toml"), "x").unwrap();
533 std::fs::remove_file(root.join(".claude/settings.local.json")).unwrap();
534 assert!(
535 before
536 .drift(
537 &JudgeSnapshot::take(root, &policy),
538 &policy,
539 &BTreeSet::new()
540 )
541 .is_empty()
542 );
543 }
544
545 #[test]
549 fn a_judge_input_in_another_letter_case_is_drift() {
550 use crate::tools::policy::Policy;
551 let dir = tempfile::tempdir().unwrap();
552 let root = dir.path();
553 let policy = Policy::new(&[], &[]);
554 let before = JudgeSnapshot::take(root, &policy);
555 std::fs::create_dir_all(root.join(".CLAUDE")).unwrap();
556 std::fs::write(root.join(".CLAUDE/settings.json"), "{}").unwrap();
557 std::fs::create_dir_all(root.join(".Cursor")).unwrap();
558 std::fs::write(root.join(".Cursor/rules.json"), "{}").unwrap();
559 std::fs::write(root.join("Makefile.x"), "free").unwrap();
560 std::fs::write(root.join("MAKEFILE"), "all:").unwrap();
561 let after = JudgeSnapshot::take(root, &policy);
562 let drift: Vec<&str> = before
563 .drift(&after, &policy, &BTreeSet::new())
564 .iter()
565 .map(|d| d.class)
566 .collect();
567 assert_eq!(drift, ["judge", "judge", "judge"], "{:?}", after.0.keys());
568 let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
570 assert_eq!(
571 audit(
572 &[change("Tests/API.rs", ChangeKind::Deleted)],
573 "",
574 &frozen,
575 false,
576 &BTreeSet::new()
577 ),
578 ["frozen test deleted: Tests/API.rs"]
579 );
580 }
581
582 #[test]
583 fn a_placeholder_test_is_known_in_each_language() {
584 for text in [
585 "def test_a():\n raise NotImplementedError(\"later\")\n",
586 "#[test] fn a() { todo!() }",
587 "#[test] fn a() { unimplemented!(\"x\") }",
588 "it('a', () => { throw new Error('Not implemented') })",
589 "func TestA(t *testing.T) { t.Fatal(\"not implemented\") }",
590 ] {
591 assert!(has_placeholder(text), "{text}");
592 }
593 for text in [
594 "def test_a():\n with pytest.raises(NotImplementedError):\n f()\n",
595 "#[test] fn a() { assert_eq!(f(), 2) }",
596 "it('a', () => expect(() => f()).toThrow('not implemented'))",
597 ] {
598 assert!(!has_placeholder(text), "{text}");
599 }
600 }
601
602 #[test]
605 fn crlf_asserted_on_text_output_never_passes() {
606 let run = "r = subprocess.run(cmd, input=t, text=True, capture_output=True)\n";
607 assert!(asserts_crlf_as_text(
608 "tests/test_us1.py",
609 &format!("{run}assert r.stdout == \"a,b\\r\\n\"\n")
610 ));
611 assert!(!asserts_crlf_as_text(
613 "tests/test_us1.py",
614 &format!("{run}assert r.stdout.splitlines() == [\"a,b\"]\n")
615 ));
616 assert!(!asserts_crlf_as_text(
617 "tests/test_us1.py",
618 "r = subprocess.run(cmd, input=b\"x\", capture_output=True)\nassert r.stdout == b\"a,b\\r\\n\"\n"
619 ));
620 assert!(!asserts_crlf_as_text(
621 "tests/test_us1.py",
622 &format!("t = \"| a |\\r\\n\"\n{run}assert r.stdout == \"a\\n\"\n")
623 ));
624 assert_eq!(
626 never_passes(
627 "tests/test_us1.py",
628 &format!("{run}assert r.stdout == \"a,b\\r\\n\"\n")
629 ),
630 Some("it asserts \\r\\n in output read with text=True, which turns it into \\n")
631 );
632 assert_eq!(
633 never_passes("tests/test_us1.py", &format!("{run}assert r.stdout\n")),
634 None
635 );
636 }
637
638 #[test]
641 fn python_s_invalid_escape_warning_names_its_own_test() {
642 let py315 = " /w/tests/test_acceptance_us3_2.py:15: SyntaxWarning: \"\\|\" is an invalid escape sequence.\n";
643 let py312 =
644 "tests/test_acceptance_us3_2.py:15: SyntaxWarning: invalid escape sequence '\\|'\n";
645 for out in [py315, py312] {
646 assert!(
647 invalid_escape(out, &["tests/test_acceptance_us3_2.py"]).is_some(),
648 "{out}"
649 );
650 }
651 assert_eq!(
653 invalid_escape(py312, &["tests/test_acceptance_us3_1.py"]),
654 None
655 );
656 }
657}