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)|\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
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/// Markers of a placeholder: a test that fails by saying it is not
174/// written, never by checking anything.
175const 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/// Whether `text` is a placeholder: it fails by raising "not implemented"
178/// (`raise NotImplementedError`, `todo!()`, `unimplemented!()`, …) — red
179/// that no implementation can turn green.
180#[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/// Whether a Python test asserts `\r\n` in output it reads as text
186/// (`text=True`, `universal_newlines=True`): text mode turns `\r\n` into
187/// `\n`, so it fails whatever the code writes (the example's US1, 2026-11-03).
188#[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/// Python's warning of an invalid escape in one of `paths`' own string
198/// literals, from a run's output (`test_x.py:15: SyntaxWarning: "\\|" is an
199/// invalid escape sequence`): the string holds what its author did not
200/// write (the example's US3-2, 2026-11-04).
201#[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/// Why a test can never pass, whatever the code, when speccoder can tell:
213/// the words a ruling on it gives.
214#[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/// Whether `text` holds a marker that skips a test.
226#[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/// Whether every line of `old` survives, unchanged and in order, in `new`:
232/// the change only adds lines (`[tests] frozen = "additions"`).
233#[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/// Findings for an attempt: `changes` and `patch` between its checkpoints,
240/// `frozen` the test files that existed when it started. With
241/// `additions_ok`, a frozen test whose diff only adds lines is no finding
242/// (removed tests and skip markers still are). An agent context file
243/// (`AGENTS.md`, …) changed without a person's grant in `approved` is one.
244#[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/// [`audit`] against the protected set of the project's format.
263#[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(); // (added, removed)
302    let mut text: BTreeMap<String, (String, String)> = BTreeMap::new(); // (added, removed)
303    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            // `diff --git a/<path> b/<path>` (no renames): the path is the
308            // same on both sides, also for additions and deletions.
309            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            // File headers; the name came from `diff --git`.
315        } 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        // A deleted file is reported as such (frozen) or is new work undone.
341        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
362/// Removed lines per file in a unified diff.
363fn 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/// Lowercase hex SHA-256 of `bytes`: the content hash of a judge input or a
380/// task list, as the format adapters' write guards compare it.
381#[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
389/// The bytes with every `[x]`/`[X]` checkbox read as `[ ]`.
390pub(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        // A person's grant for the context file exempts it.
528        let approved: BTreeSet<String> = ["AGENTS.md".to_owned()].into();
529        assert_eq!(before.drift(&after, &policy, &approved).len(), 2);
530        // Restored content is no drift.
531        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    /// An agent configuration created under another letter case — the
546    /// directory absent when the attempt began — is a judge input: on a
547    /// case-insensitive volume the agent loads it as `.claude/…`.
548    #[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        // A frozen test is frozen in any letter case.
569        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    /// The example's US1 oracle (2026-11-03): CSV's `\r\n` asserted on
603    /// output read as text, which can never hold it.
604    #[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        // Its lines, or bytes, or CRLF only in the input: fine.
612        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        // At the stop, the ruling's reason.
625        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    /// The example's US3-2 oracle (2026-11-04): its string held a backslash
639    /// more than its author meant; Python says so, in either form.
640    #[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        // Another file's warning is not this test's.
652        assert_eq!(
653            invalid_escape(py312, &["tests/test_acceptance_us3_1.py"]),
654            None
655        );
656    }
657}