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)|\.skip\s*\(|\bx(it|describe|test)\s*\(|@Disabled\b|@Ignore\b|\bt\.Skip(Now)?\s*\(|@unittest\.skip|\.todo\s*\(";
144
145#[must_use]
148pub fn only_additions(old: &str, new: &str) -> bool {
149 let mut new_lines = new.lines();
150 old.lines().all(|line| new_lines.any(|n| n == line))
151}
152
153#[must_use]
159pub fn audit(
160 changes: &[Change],
161 patch: &str,
162 frozen: &BTreeSet<String>,
163 additions_ok: bool,
164 approved: &BTreeSet<String>,
165) -> Vec<String> {
166 audit_in(
167 &Protected::default(),
168 changes,
169 patch,
170 frozen,
171 additions_ok,
172 approved,
173 )
174}
175
176#[must_use]
178pub fn audit_in(
179 protected: &Protected,
180 changes: &[Change],
181 patch: &str,
182 frozen: &BTreeSet<String>,
183 additions_ok: bool,
184 approved: &BTreeSet<String>,
185) -> Vec<String> {
186 let mut findings = Vec::new();
187 let removed_lines = removed_lines(patch);
188
189 for c in changes {
190 if frozen.contains(&c.path) {
191 match c.kind {
192 ChangeKind::Deleted => findings.push(format!("frozen test deleted: {}", c.path)),
193 ChangeKind::Modified
194 if additions_ok && removed_lines.get(&c.path).copied().unwrap_or(0) == 0 => {}
195 ChangeKind::Modified => findings.push(format!("frozen test modified: {}", c.path)),
196 ChangeKind::Added => {}
197 }
198 }
199 match protected.write_class(&c.path) {
200 Some(Class::Judge) => findings.push(format!("verification input changed: {}", c.path)),
201 Some(Class::Intent) => findings.push(format!("spec changed: {}", c.path)),
202 None if protected.is_context(&c.path) && !approved.contains(&c.path) => {
203 findings.push(format!("agent context file changed: {}", c.path));
204 }
205 _ => {}
206 }
207 }
208
209 let (Ok(test), Ok(skip)) = (Regex::new(TEST_MARKERS), Regex::new(SKIP_MARKERS)) else {
210 return findings;
211 };
212 let mut tests: BTreeMap<String, (usize, usize)> = BTreeMap::new(); let mut skips: BTreeSet<String> = BTreeSet::new();
214 let mut file = String::new();
215 for line in patch.lines() {
216 if let Some(header) = line.strip_prefix("diff --git a/") {
217 file = header
220 .split_once(" b/")
221 .map_or(header, |(a, _)| a)
222 .to_owned();
223 } else if line.starts_with("+++ ") || line.starts_with("--- ") {
224 } else if let Some(added) = line.strip_prefix('+') {
226 if test.is_match(added) {
227 tests.entry(file.clone()).or_default().0 += 1;
228 }
229 if skip.is_match(added) {
230 skips.insert(file.clone());
231 }
232 } else if let Some(removed) = line.strip_prefix('-')
233 && test.is_match(removed)
234 {
235 tests.entry(file.clone()).or_default().1 += 1;
236 }
237 }
238 let deleted: BTreeSet<&str> = changes
239 .iter()
240 .filter(|c| c.kind == ChangeKind::Deleted)
241 .map(|c| c.path.as_str())
242 .collect();
243 for (path, (added, removed)) in tests {
244 if removed > added && !deleted.contains(path.as_str()) {
246 findings.push(format!(
247 "test removed in {path} ({removed} removed, {added} added)"
248 ));
249 }
250 }
251 for path in skips {
252 findings.push(format!("skip marker added in {path}"));
253 }
254 findings
255}
256
257fn removed_lines(patch: &str) -> BTreeMap<String, usize> {
259 let mut out: BTreeMap<String, usize> = BTreeMap::new();
260 let mut file = String::new();
261 for line in patch.lines() {
262 if let Some(header) = line.strip_prefix("diff --git a/") {
263 file = header
264 .split_once(" b/")
265 .map_or(header, |(a, _)| a)
266 .to_owned();
267 } else if line.starts_with('-') && !line.starts_with("--- ") {
268 *out.entry(file.clone()).or_default() += 1;
269 }
270 }
271 out
272}
273
274#[must_use]
277pub fn sha256_hex(bytes: &[u8]) -> String {
278 Sha256::digest(bytes)
279 .iter()
280 .map(|b| format!("{b:02x}"))
281 .collect()
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn change(path: &str, kind: ChangeKind) -> Change {
289 Change {
290 path: path.to_owned(),
291 kind,
292 }
293 }
294
295 #[test]
296 fn flags_tampering_and_passes_clean_diffs() {
297 let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
298 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";
299 let f = audit(
300 &[
301 change("tests/api.rs", ChangeKind::Deleted),
302 change("speccoder.toml", ChangeKind::Modified),
303 ],
304 patch,
305 &frozen,
306 false,
307 &BTreeSet::new(),
308 );
309 assert_eq!(
310 f,
311 [
312 "frozen test deleted: tests/api.rs",
313 "verification input changed: speccoder.toml",
314 "skip marker added in src/lib.rs",
315 ]
316 );
317
318 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";
319 assert_eq!(
320 audit(&[], removed, &BTreeSet::new(), false, &BTreeSet::new()),
321 ["test removed in src/lib.rs (1 removed, 0 added)"]
322 );
323
324 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";
325 assert!(
326 audit(
327 &[
328 change("src/lib.rs", ChangeKind::Modified),
329 change("tests/new.rs", ChangeKind::Added)
330 ],
331 clean,
332 &frozen,
333 false,
334 &BTreeSet::new()
335 )
336 .is_empty()
337 );
338 }
339
340 #[test]
341 fn additions_to_a_frozen_test_pass_only_in_additions_mode() {
342 let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
343 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";
344 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";
345 let modified = [change("tests/api.rs", ChangeKind::Modified)];
346 assert!(audit(&modified, added, &frozen, true, &BTreeSet::new()).is_empty());
347 assert_eq!(
348 audit(&modified, added, &frozen, false, &BTreeSet::new()),
349 ["frozen test modified: tests/api.rs"]
350 );
351 assert_eq!(
352 audit(&modified, changed, &frozen, true, &BTreeSet::new()),
353 ["frozen test modified: tests/api.rs"]
354 );
355
356 assert!(only_additions("a\nb\n", "a\nnew\nb\nmore\n"));
357 assert!(!only_additions("a\nb\n", "a\nB\n"));
358 assert!(!only_additions("a\nb\n", "b\na\n"));
359 }
360
361 #[test]
362 fn a_snapshot_sees_ignored_judge_inputs_and_reports_drift() {
363 use crate::tools::policy::Policy;
364 let dir = tempfile::tempdir().unwrap();
365 let root = dir.path();
366 std::fs::create_dir_all(root.join(".claude")).unwrap();
367 std::fs::create_dir_all(root.join("target/debug")).unwrap();
368 std::fs::write(root.join(".claude/settings.json"), "{}").unwrap();
369 std::fs::write(root.join("AGENTS.md"), "a").unwrap();
370 std::fs::write(root.join("speccoder.toml"), "x").unwrap();
371 std::fs::write(root.join("src.rs"), "free").unwrap();
372 std::fs::write(root.join("target/debug/speccoder.toml"), "skipped").unwrap();
373 let policy = Policy::new(&[], &[]);
374 let before = JudgeSnapshot::take(root, &policy);
375 assert_eq!(
376 before.0.len(),
377 3,
378 "{:?}",
379 before.0.keys().collect::<Vec<_>>()
380 );
381
382 std::fs::write(root.join(".claude/settings.local.json"), "{}").unwrap();
383 std::fs::write(root.join("AGENTS.md"), "b").unwrap();
384 std::fs::remove_file(root.join("speccoder.toml")).unwrap();
385 std::fs::write(root.join("src.rs"), "changed").unwrap();
386 let after = JudgeSnapshot::take(root, &policy);
387 let drift = before.drift(&after, &policy, &BTreeSet::new());
388 let seen: Vec<(&str, &str, &str)> = drift
389 .iter()
390 .map(|d| (d.path.as_str(), d.class, d.change))
391 .collect();
392 assert_eq!(
393 seen,
394 [
395 (".claude/settings.local.json", "judge", "added"),
396 ("AGENTS.md", "context", "modified"),
397 ("speccoder.toml", "judge", "deleted"),
398 ]
399 );
400 let approved: BTreeSet<String> = ["AGENTS.md".to_owned()].into();
402 assert_eq!(before.drift(&after, &policy, &approved).len(), 2);
403 std::fs::write(root.join("AGENTS.md"), "a").unwrap();
405 std::fs::write(root.join("speccoder.toml"), "x").unwrap();
406 std::fs::remove_file(root.join(".claude/settings.local.json")).unwrap();
407 assert!(
408 before
409 .drift(
410 &JudgeSnapshot::take(root, &policy),
411 &policy,
412 &BTreeSet::new()
413 )
414 .is_empty()
415 );
416 }
417}