Skip to main content

speccoder_runtime/
integrity.rs

1//! The integrity audit of an attempt's diff.
2//!
3//! Line-based and deliberately suspicious: it errs on flagging. Any finding
4//! fails the verdict with an `integrity:` reason; a person looks.
5
6use 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
16/// Directories a snapshot never descends into: build output and installed
17/// dependencies, where no judge input lives.
18const 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/// The judge, intent and agent context inputs of a worktree by content hash,
31/// git-ignored files included (`.claude/settings.local.json` is ignored by
32/// default, and an agent still loads it).
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct JudgeSnapshot(BTreeMap<String, String>);
35
36/// A judge input that changed between two snapshots.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38pub struct Drift {
39    /// Workspace-relative path.
40    pub path: String,
41    /// `judge`, `intent` or `context`.
42    pub class: &'static str,
43    /// `added`, `modified` or `deleted`.
44    pub change: &'static str,
45}
46
47impl JudgeSnapshot {
48    /// Hashes every judge, intent and context input under `root` as `policy`
49    /// sees them (a planning skill may write Spec Kit's artifacts).
50    #[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    /// Path → content hash.
86    #[must_use]
87    pub fn entries(&self) -> &BTreeMap<String, String> {
88        &self.0
89    }
90
91    /// A snapshot from recorded entries.
92    #[must_use]
93    pub fn from_entries(entries: BTreeMap<String, String>) -> Self {
94        Self(entries)
95    }
96
97    /// Records `path`'s new content hash (the engine's own sanctioned write).
98    pub fn set(&mut self, path: &str, hash: String) {
99        self.0.insert(path.to_owned(), hash);
100    }
101
102    /// What changed from `self` to `later`, except `approved` paths (a
103    /// person's grant to write them).
104    #[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
140/// Markers that declare a test.
141const 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";
142/// Markers that skip a test.
143const 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/// Test definitions with their names: Rust `#[test] fn`, Python `def
146/// test_`, Go `func Test`, JavaScript `it("…")` / `test("…")`.
147const 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/// The tests `before` defines that `after` no longer does, in order.
150#[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
173/// Whether every line of `old` survives, unchanged and in order, in `new`:
174/// the change only adds lines (`[tests] frozen = "additions"`).
175#[must_use]
176pub fn only_additions(old: &str, new: &str) -> bool {
177    let mut new_lines = new.lines();
178    old.lines().all(|line| new_lines.any(|n| n == line))
179}
180
181/// Findings for an attempt: `changes` and `patch` between its checkpoints,
182/// `frozen` the test files that existed when it started. With
183/// `additions_ok`, a frozen test whose diff only adds lines is no finding
184/// (removed tests and skip markers still are). An agent context file
185/// (`AGENTS.md`, …) changed without a person's grant in `approved` is one.
186#[must_use]
187pub fn audit(
188    changes: &[Change],
189    patch: &str,
190    frozen: &BTreeSet<String>,
191    additions_ok: bool,
192    approved: &BTreeSet<String>,
193) -> Vec<String> {
194    audit_in(
195        &Protected::default(),
196        changes,
197        patch,
198        frozen,
199        additions_ok,
200        approved,
201    )
202}
203
204/// [`audit`] against the protected set of the project's format.
205#[must_use]
206pub fn audit_in(
207    protected: &Protected,
208    changes: &[Change],
209    patch: &str,
210    frozen: &BTreeSet<String>,
211    additions_ok: bool,
212    approved: &BTreeSet<String>,
213) -> Vec<String> {
214    let mut findings = Vec::new();
215    let removed_lines = removed_lines(patch);
216
217    for c in changes {
218        if frozen
219            .iter()
220            .any(|f| f.to_lowercase() == c.path.to_lowercase())
221        {
222            match c.kind {
223                ChangeKind::Deleted => findings.push(format!("frozen test deleted: {}", c.path)),
224                ChangeKind::Modified
225                    if additions_ok && removed_lines.get(&c.path).copied().unwrap_or(0) == 0 => {}
226                ChangeKind::Modified => findings.push(format!("frozen test modified: {}", c.path)),
227                ChangeKind::Added => {}
228            }
229        }
230        match protected.write_class(&c.path) {
231            Some(Class::Judge) => findings.push(format!("verification input changed: {}", c.path)),
232            Some(Class::Intent) => findings.push(format!("spec changed: {}", c.path)),
233            None if protected.is_context(&c.path) && !approved.contains(&c.path) => {
234                findings.push(format!("agent context file changed: {}", c.path));
235            }
236            _ => {}
237        }
238    }
239
240    let (Ok(test), Ok(skip)) = (Regex::new(TEST_MARKERS), Regex::new(SKIP_MARKERS)) else {
241        return findings;
242    };
243    let mut tests: BTreeMap<String, (usize, usize)> = BTreeMap::new(); // (added, removed)
244    let mut text: BTreeMap<String, (String, String)> = BTreeMap::new(); // (added, removed)
245    let mut skips: BTreeSet<String> = BTreeSet::new();
246    let mut file = String::new();
247    for line in patch.lines() {
248        if let Some(header) = line.strip_prefix("diff --git a/") {
249            // `diff --git a/<path> b/<path>` (no renames): the path is the
250            // same on both sides, also for additions and deletions.
251            file = header
252                .split_once(" b/")
253                .map_or(header, |(a, _)| a)
254                .to_owned();
255        } else if line.starts_with("+++ ") || line.starts_with("--- ") {
256            // File headers; the name came from `diff --git`.
257        } else if let Some(added) = line.strip_prefix('+') {
258            let t = text.entry(file.clone()).or_default();
259            t.0.push_str(added);
260            t.0.push('\n');
261            if test.is_match(added) {
262                tests.entry(file.clone()).or_default().0 += 1;
263            }
264            if skip.is_match(added) {
265                skips.insert(file.clone());
266            }
267        } else if let Some(removed) = line.strip_prefix('-') {
268            let t = text.entry(file.clone()).or_default();
269            t.1.push_str(removed);
270            t.1.push('\n');
271            if test.is_match(removed) {
272                tests.entry(file.clone()).or_default().1 += 1;
273            }
274        }
275    }
276    let deleted: BTreeSet<&str> = changes
277        .iter()
278        .filter(|c| c.kind == ChangeKind::Deleted)
279        .map(|c| c.path.as_str())
280        .collect();
281    for (path, (added, removed)) in tests {
282        // A deleted file is reported as such (frozen) or is new work undone.
283        if removed > added && !deleted.contains(path.as_str()) {
284            let names = text
285                .get(&path)
286                .map(|(a, r)| removed_tests(r, a))
287                .unwrap_or_default();
288            let named = if names.is_empty() {
289                String::new()
290            } else {
291                format!(": {}", names.join(", "))
292            };
293            findings.push(format!(
294                "test removed in {path} ({removed} removed, {added} added){named}"
295            ));
296        }
297    }
298    for path in skips {
299        findings.push(format!("skip marker added in {path}"));
300    }
301    findings
302}
303
304/// Removed lines per file in a unified diff.
305fn removed_lines(patch: &str) -> BTreeMap<String, usize> {
306    let mut out: BTreeMap<String, usize> = BTreeMap::new();
307    let mut file = String::new();
308    for line in patch.lines() {
309        if let Some(header) = line.strip_prefix("diff --git a/") {
310            file = header
311                .split_once(" b/")
312                .map_or(header, |(a, _)| a)
313                .to_owned();
314        } else if line.starts_with('-') && !line.starts_with("--- ") {
315            *out.entry(file.clone()).or_default() += 1;
316        }
317    }
318    out
319}
320
321/// Lowercase hex SHA-256 of `bytes`: the content hash of a judge input or a
322/// task list, as the format adapters' write guards compare it.
323#[must_use]
324pub fn sha256_hex(bytes: &[u8]) -> String {
325    Sha256::digest(bytes)
326        .iter()
327        .map(|b| format!("{b:02x}"))
328        .collect()
329}
330
331/// The bytes with every `[x]`/`[X]` checkbox read as `[ ]`.
332pub(crate) fn untick(bytes: &[u8]) -> Vec<u8> {
333    let mut out = bytes.to_vec();
334    for i in 1..out.len().saturating_sub(1) {
335        if out[i - 1] == b'[' && out[i + 1] == b']' && matches!(out[i], b'x' | b'X') {
336            out[i] = b' ';
337        }
338    }
339    out
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn change(path: &str, kind: ChangeKind) -> Change {
347        Change {
348            path: path.to_owned(),
349            kind,
350        }
351    }
352
353    #[test]
354    fn removed_tests_are_named_across_languages() {
355        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";
356        assert_eq!(
357            removed_tests(before, "#[test]\nfn later() {}\n"),
358            ["it_works", "test_py", "TestGo", "greets"]
359        );
360        assert!(removed_tests(before, before).is_empty());
361    }
362
363    #[test]
364    fn flags_tampering_and_passes_clean_diffs() {
365        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
366        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";
367        let f = audit(
368            &[
369                change("tests/api.rs", ChangeKind::Deleted),
370                change("speccoder.toml", ChangeKind::Modified),
371            ],
372            patch,
373            &frozen,
374            false,
375            &BTreeSet::new(),
376        );
377        assert_eq!(
378            f,
379            [
380                "frozen test deleted: tests/api.rs",
381                "verification input changed: speccoder.toml",
382                "skip marker added in src/lib.rs",
383            ]
384        );
385
386        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";
387        assert_eq!(
388            audit(&[], removed, &BTreeSet::new(), false, &BTreeSet::new()),
389            ["test removed in src/lib.rs (1 removed, 0 added): a"]
390        );
391
392        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";
393        assert!(
394            audit(
395                &[
396                    change("src/lib.rs", ChangeKind::Modified),
397                    change("tests/new.rs", ChangeKind::Added)
398                ],
399                clean,
400                &frozen,
401                false,
402                &BTreeSet::new()
403            )
404            .is_empty()
405        );
406    }
407
408    #[test]
409    fn additions_to_a_frozen_test_pass_only_in_additions_mode() {
410        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
411        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";
412        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";
413        let modified = [change("tests/api.rs", ChangeKind::Modified)];
414        assert!(audit(&modified, added, &frozen, true, &BTreeSet::new()).is_empty());
415        assert_eq!(
416            audit(&modified, added, &frozen, false, &BTreeSet::new()),
417            ["frozen test modified: tests/api.rs"]
418        );
419        assert_eq!(
420            audit(&modified, changed, &frozen, true, &BTreeSet::new()),
421            ["frozen test modified: tests/api.rs"]
422        );
423
424        assert!(only_additions("a\nb\n", "a\nnew\nb\nmore\n"));
425        assert!(!only_additions("a\nb\n", "a\nB\n"));
426        assert!(!only_additions("a\nb\n", "b\na\n"));
427    }
428
429    #[test]
430    fn a_snapshot_sees_ignored_judge_inputs_and_reports_drift() {
431        use crate::tools::policy::Policy;
432        let dir = tempfile::tempdir().unwrap();
433        let root = dir.path();
434        std::fs::create_dir_all(root.join(".claude")).unwrap();
435        std::fs::create_dir_all(root.join("target/debug")).unwrap();
436        std::fs::write(root.join(".claude/settings.json"), "{}").unwrap();
437        std::fs::write(root.join("AGENTS.md"), "a").unwrap();
438        std::fs::write(root.join("speccoder.toml"), "x").unwrap();
439        std::fs::write(root.join("src.rs"), "free").unwrap();
440        std::fs::write(root.join("target/debug/speccoder.toml"), "skipped").unwrap();
441        let policy = Policy::new(&[], &[]);
442        let before = JudgeSnapshot::take(root, &policy);
443        assert_eq!(
444            before.0.len(),
445            3,
446            "{:?}",
447            before.0.keys().collect::<Vec<_>>()
448        );
449
450        std::fs::write(root.join(".claude/settings.local.json"), "{}").unwrap();
451        std::fs::write(root.join("AGENTS.md"), "b").unwrap();
452        std::fs::remove_file(root.join("speccoder.toml")).unwrap();
453        std::fs::write(root.join("src.rs"), "changed").unwrap();
454        let after = JudgeSnapshot::take(root, &policy);
455        let drift = before.drift(&after, &policy, &BTreeSet::new());
456        let seen: Vec<(&str, &str, &str)> = drift
457            .iter()
458            .map(|d| (d.path.as_str(), d.class, d.change))
459            .collect();
460        assert_eq!(
461            seen,
462            [
463                (".claude/settings.local.json", "judge", "added"),
464                ("AGENTS.md", "context", "modified"),
465                ("speccoder.toml", "judge", "deleted"),
466            ]
467        );
468        // A person's grant for the context file exempts it.
469        let approved: BTreeSet<String> = ["AGENTS.md".to_owned()].into();
470        assert_eq!(before.drift(&after, &policy, &approved).len(), 2);
471        // Restored content is no drift.
472        std::fs::write(root.join("AGENTS.md"), "a").unwrap();
473        std::fs::write(root.join("speccoder.toml"), "x").unwrap();
474        std::fs::remove_file(root.join(".claude/settings.local.json")).unwrap();
475        assert!(
476            before
477                .drift(
478                    &JudgeSnapshot::take(root, &policy),
479                    &policy,
480                    &BTreeSet::new()
481                )
482                .is_empty()
483        );
484    }
485
486    /// An agent configuration created under another letter case — the
487    /// directory absent when the attempt began — is a judge input: on a
488    /// case-insensitive volume the agent loads it as `.claude/…`.
489    #[test]
490    fn a_judge_input_in_another_letter_case_is_drift() {
491        use crate::tools::policy::Policy;
492        let dir = tempfile::tempdir().unwrap();
493        let root = dir.path();
494        let policy = Policy::new(&[], &[]);
495        let before = JudgeSnapshot::take(root, &policy);
496        std::fs::create_dir_all(root.join(".CLAUDE")).unwrap();
497        std::fs::write(root.join(".CLAUDE/settings.json"), "{}").unwrap();
498        std::fs::create_dir_all(root.join(".Cursor")).unwrap();
499        std::fs::write(root.join(".Cursor/rules.json"), "{}").unwrap();
500        std::fs::write(root.join("Makefile.x"), "free").unwrap();
501        std::fs::write(root.join("MAKEFILE"), "all:").unwrap();
502        let after = JudgeSnapshot::take(root, &policy);
503        let drift: Vec<&str> = before
504            .drift(&after, &policy, &BTreeSet::new())
505            .iter()
506            .map(|d| d.class)
507            .collect();
508        assert_eq!(drift, ["judge", "judge", "judge"], "{:?}", after.0.keys());
509        // A frozen test is frozen in any letter case.
510        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
511        assert_eq!(
512            audit(
513                &[change("Tests/API.rs", ChangeKind::Deleted)],
514                "",
515                &frozen,
516                false,
517                &BTreeSet::new()
518            ),
519            ["frozen test deleted: Tests/API.rs"]
520        );
521    }
522}