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/// The section `new` adds at the end of `old`: `None` when `new` changes,
174/// removes or inserts among `old`'s lines, or adds nothing.
175#[must_use]
176pub fn appended_section<'n>(old: &str, new: &'n str) -> Option<&'n str> {
177    // The old text whole, then new lines: never a line inserted among it.
178    let added = new.strip_prefix(old).or_else(|| {
179        (!old.ends_with('\n'))
180            .then(|| new.strip_prefix(&format!("{old}\n")))
181            .flatten()
182    })?;
183    (!added.trim().is_empty()).then_some(added)
184}
185
186/// Whether `text` holds a marker that skips a test.
187#[must_use]
188pub fn has_skip_marker(text: &str) -> bool {
189    Regex::new(SKIP_MARKERS).is_ok_and(|r| r.is_match(text))
190}
191
192/// Whether every line of `old` survives, unchanged and in order, in `new`:
193/// the change only adds lines (`[tests] frozen = "additions"`).
194#[must_use]
195pub fn only_additions(old: &str, new: &str) -> bool {
196    let mut new_lines = new.lines();
197    old.lines().all(|line| new_lines.any(|n| n == line))
198}
199
200/// Findings for an attempt: `changes` and `patch` between its checkpoints,
201/// `frozen` the test files that existed when it started. With
202/// `additions_ok`, a frozen test whose diff only adds lines is no finding
203/// (removed tests and skip markers still are). An agent context file
204/// (`AGENTS.md`, …) changed without a person's grant in `approved` is one.
205#[must_use]
206pub fn audit(
207    changes: &[Change],
208    patch: &str,
209    frozen: &BTreeSet<String>,
210    additions_ok: bool,
211    approved: &BTreeSet<String>,
212) -> Vec<String> {
213    audit_in(
214        &Protected::default(),
215        changes,
216        patch,
217        frozen,
218        additions_ok,
219        approved,
220    )
221}
222
223/// [`audit`] against the protected set of the project's format.
224#[must_use]
225pub fn audit_in(
226    protected: &Protected,
227    changes: &[Change],
228    patch: &str,
229    frozen: &BTreeSet<String>,
230    additions_ok: bool,
231    approved: &BTreeSet<String>,
232) -> Vec<String> {
233    let mut findings = Vec::new();
234    let removed_lines = removed_lines(patch);
235
236    for c in changes {
237        if frozen
238            .iter()
239            .any(|f| f.to_lowercase() == c.path.to_lowercase())
240        {
241            match c.kind {
242                ChangeKind::Deleted => findings.push(format!("frozen test deleted: {}", c.path)),
243                ChangeKind::Modified
244                    if additions_ok && removed_lines.get(&c.path).copied().unwrap_or(0) == 0 => {}
245                ChangeKind::Modified => findings.push(format!("frozen test modified: {}", c.path)),
246                ChangeKind::Added => {}
247            }
248        }
249        match protected.write_class(&c.path) {
250            Some(Class::Judge) => findings.push(format!("verification input changed: {}", c.path)),
251            Some(Class::Intent) => findings.push(format!("spec changed: {}", c.path)),
252            None if protected.is_context(&c.path) && !approved.contains(&c.path) => {
253                findings.push(format!("agent context file changed: {}", c.path));
254            }
255            _ => {}
256        }
257    }
258
259    let (Ok(test), Ok(skip)) = (Regex::new(TEST_MARKERS), Regex::new(SKIP_MARKERS)) else {
260        return findings;
261    };
262    let mut tests: BTreeMap<String, (usize, usize)> = BTreeMap::new(); // (added, removed)
263    let mut text: BTreeMap<String, (String, String)> = BTreeMap::new(); // (added, removed)
264    let mut skips: BTreeSet<String> = BTreeSet::new();
265    let mut file = String::new();
266    for line in patch.lines() {
267        if let Some(header) = line.strip_prefix("diff --git a/") {
268            // `diff --git a/<path> b/<path>` (no renames): the path is the
269            // same on both sides, also for additions and deletions.
270            file = header
271                .split_once(" b/")
272                .map_or(header, |(a, _)| a)
273                .to_owned();
274        } else if line.starts_with("+++ ") || line.starts_with("--- ") {
275            // File headers; the name came from `diff --git`.
276        } else if let Some(added) = line.strip_prefix('+') {
277            let t = text.entry(file.clone()).or_default();
278            t.0.push_str(added);
279            t.0.push('\n');
280            if test.is_match(added) {
281                tests.entry(file.clone()).or_default().0 += 1;
282            }
283            if skip.is_match(added) {
284                skips.insert(file.clone());
285            }
286        } else if let Some(removed) = line.strip_prefix('-') {
287            let t = text.entry(file.clone()).or_default();
288            t.1.push_str(removed);
289            t.1.push('\n');
290            if test.is_match(removed) {
291                tests.entry(file.clone()).or_default().1 += 1;
292            }
293        }
294    }
295    let deleted: BTreeSet<&str> = changes
296        .iter()
297        .filter(|c| c.kind == ChangeKind::Deleted)
298        .map(|c| c.path.as_str())
299        .collect();
300    for (path, (added, removed)) in tests {
301        // A deleted file is reported as such (frozen) or is new work undone.
302        if removed > added && !deleted.contains(path.as_str()) {
303            let names = text
304                .get(&path)
305                .map(|(a, r)| removed_tests(r, a))
306                .unwrap_or_default();
307            let named = if names.is_empty() {
308                String::new()
309            } else {
310                format!(": {}", names.join(", "))
311            };
312            findings.push(format!(
313                "test removed in {path} ({removed} removed, {added} added){named}"
314            ));
315        }
316    }
317    for path in skips {
318        findings.push(format!("skip marker added in {path}"));
319    }
320    findings
321}
322
323/// Removed lines per file in a unified diff.
324fn removed_lines(patch: &str) -> BTreeMap<String, usize> {
325    let mut out: BTreeMap<String, usize> = BTreeMap::new();
326    let mut file = String::new();
327    for line in patch.lines() {
328        if let Some(header) = line.strip_prefix("diff --git a/") {
329            file = header
330                .split_once(" b/")
331                .map_or(header, |(a, _)| a)
332                .to_owned();
333        } else if line.starts_with('-') && !line.starts_with("--- ") {
334            *out.entry(file.clone()).or_default() += 1;
335        }
336    }
337    out
338}
339
340/// Lowercase hex SHA-256 of `bytes`: the content hash of a judge input or a
341/// task list, as the format adapters' write guards compare it.
342#[must_use]
343pub fn sha256_hex(bytes: &[u8]) -> String {
344    Sha256::digest(bytes)
345        .iter()
346        .map(|b| format!("{b:02x}"))
347        .collect()
348}
349
350/// The bytes with every `[x]`/`[X]` checkbox read as `[ ]`.
351pub(crate) fn untick(bytes: &[u8]) -> Vec<u8> {
352    let mut out = bytes.to_vec();
353    for i in 1..out.len().saturating_sub(1) {
354        if out[i - 1] == b'[' && out[i + 1] == b']' && matches!(out[i], b'x' | b'X') {
355            out[i] = b' ';
356        }
357    }
358    out
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn change(path: &str, kind: ChangeKind) -> Change {
366        Change {
367            path: path.to_owned(),
368            kind,
369        }
370    }
371
372    #[test]
373    fn removed_tests_are_named_across_languages() {
374        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";
375        assert_eq!(
376            removed_tests(before, "#[test]\nfn later() {}\n"),
377            ["it_works", "test_py", "TestGo", "greets"]
378        );
379        assert!(removed_tests(before, before).is_empty());
380    }
381
382    #[test]
383    fn flags_tampering_and_passes_clean_diffs() {
384        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
385        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";
386        let f = audit(
387            &[
388                change("tests/api.rs", ChangeKind::Deleted),
389                change("speccoder.toml", ChangeKind::Modified),
390            ],
391            patch,
392            &frozen,
393            false,
394            &BTreeSet::new(),
395        );
396        assert_eq!(
397            f,
398            [
399                "frozen test deleted: tests/api.rs",
400                "verification input changed: speccoder.toml",
401                "skip marker added in src/lib.rs",
402            ]
403        );
404
405        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";
406        assert_eq!(
407            audit(&[], removed, &BTreeSet::new(), false, &BTreeSet::new()),
408            ["test removed in src/lib.rs (1 removed, 0 added): a"]
409        );
410
411        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";
412        assert!(
413            audit(
414                &[
415                    change("src/lib.rs", ChangeKind::Modified),
416                    change("tests/new.rs", ChangeKind::Added)
417                ],
418                clean,
419                &frozen,
420                false,
421                &BTreeSet::new()
422            )
423            .is_empty()
424        );
425    }
426
427    #[test]
428    fn additions_to_a_frozen_test_pass_only_in_additions_mode() {
429        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
430        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";
431        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";
432        let modified = [change("tests/api.rs", ChangeKind::Modified)];
433        assert!(audit(&modified, added, &frozen, true, &BTreeSet::new()).is_empty());
434        assert_eq!(
435            audit(&modified, added, &frozen, false, &BTreeSet::new()),
436            ["frozen test modified: tests/api.rs"]
437        );
438        assert_eq!(
439            audit(&modified, changed, &frozen, true, &BTreeSet::new()),
440            ["frozen test modified: tests/api.rs"]
441        );
442
443        assert!(only_additions("a\nb\n", "a\nnew\nb\nmore\n"));
444        assert!(!only_additions("a\nb\n", "a\nB\n"));
445        assert!(!only_additions("a\nb\n", "b\na\n"));
446    }
447
448    #[test]
449    fn a_snapshot_sees_ignored_judge_inputs_and_reports_drift() {
450        use crate::tools::policy::Policy;
451        let dir = tempfile::tempdir().unwrap();
452        let root = dir.path();
453        std::fs::create_dir_all(root.join(".claude")).unwrap();
454        std::fs::create_dir_all(root.join("target/debug")).unwrap();
455        std::fs::write(root.join(".claude/settings.json"), "{}").unwrap();
456        std::fs::write(root.join("AGENTS.md"), "a").unwrap();
457        std::fs::write(root.join("speccoder.toml"), "x").unwrap();
458        std::fs::write(root.join("src.rs"), "free").unwrap();
459        std::fs::write(root.join("target/debug/speccoder.toml"), "skipped").unwrap();
460        let policy = Policy::new(&[], &[]);
461        let before = JudgeSnapshot::take(root, &policy);
462        assert_eq!(
463            before.0.len(),
464            3,
465            "{:?}",
466            before.0.keys().collect::<Vec<_>>()
467        );
468
469        std::fs::write(root.join(".claude/settings.local.json"), "{}").unwrap();
470        std::fs::write(root.join("AGENTS.md"), "b").unwrap();
471        std::fs::remove_file(root.join("speccoder.toml")).unwrap();
472        std::fs::write(root.join("src.rs"), "changed").unwrap();
473        let after = JudgeSnapshot::take(root, &policy);
474        let drift = before.drift(&after, &policy, &BTreeSet::new());
475        let seen: Vec<(&str, &str, &str)> = drift
476            .iter()
477            .map(|d| (d.path.as_str(), d.class, d.change))
478            .collect();
479        assert_eq!(
480            seen,
481            [
482                (".claude/settings.local.json", "judge", "added"),
483                ("AGENTS.md", "context", "modified"),
484                ("speccoder.toml", "judge", "deleted"),
485            ]
486        );
487        // A person's grant for the context file exempts it.
488        let approved: BTreeSet<String> = ["AGENTS.md".to_owned()].into();
489        assert_eq!(before.drift(&after, &policy, &approved).len(), 2);
490        // Restored content is no drift.
491        std::fs::write(root.join("AGENTS.md"), "a").unwrap();
492        std::fs::write(root.join("speccoder.toml"), "x").unwrap();
493        std::fs::remove_file(root.join(".claude/settings.local.json")).unwrap();
494        assert!(
495            before
496                .drift(
497                    &JudgeSnapshot::take(root, &policy),
498                    &policy,
499                    &BTreeSet::new()
500                )
501                .is_empty()
502        );
503    }
504
505    /// An agent configuration created under another letter case — the
506    /// directory absent when the attempt began — is a judge input: on a
507    /// case-insensitive volume the agent loads it as `.claude/…`.
508    #[test]
509    fn a_judge_input_in_another_letter_case_is_drift() {
510        use crate::tools::policy::Policy;
511        let dir = tempfile::tempdir().unwrap();
512        let root = dir.path();
513        let policy = Policy::new(&[], &[]);
514        let before = JudgeSnapshot::take(root, &policy);
515        std::fs::create_dir_all(root.join(".CLAUDE")).unwrap();
516        std::fs::write(root.join(".CLAUDE/settings.json"), "{}").unwrap();
517        std::fs::create_dir_all(root.join(".Cursor")).unwrap();
518        std::fs::write(root.join(".Cursor/rules.json"), "{}").unwrap();
519        std::fs::write(root.join("Makefile.x"), "free").unwrap();
520        std::fs::write(root.join("MAKEFILE"), "all:").unwrap();
521        let after = JudgeSnapshot::take(root, &policy);
522        let drift: Vec<&str> = before
523            .drift(&after, &policy, &BTreeSet::new())
524            .iter()
525            .map(|d| d.class)
526            .collect();
527        assert_eq!(drift, ["judge", "judge", "judge"], "{:?}", after.0.keys());
528        // A frozen test is frozen in any letter case.
529        let frozen: BTreeSet<String> = ["tests/api.rs".to_owned()].into();
530        assert_eq!(
531            audit(
532                &[change("Tests/API.rs", ChangeKind::Deleted)],
533                "",
534                &frozen,
535                false,
536                &BTreeSet::new()
537            ),
538            ["frozen test deleted: Tests/API.rs"]
539        );
540    }
541}