Skip to main content

speccoder_runtime/pipeline/
bisect.rs

1//! Which tick made already-passing acceptance tests pass: a fact
2//! found by running the tests, never a guess from diffs.
3//!
4//! The feature branch's tick commits are searched by bisection — at most
5//! five runs, each with the acceptance files overlaid on the commit's tree
6//! in a scratch worktree of its own under the state directory, sandboxed,
7//! under the checks' deadline. A run that builds and fails says "not
8//! passing there"; anything that cannot say — a worktree that cannot be
9//! made, a run that times out or prints nothing readable — makes the
10//! answer "could not be determined", never a task's name. For a section
11//! added to a story's file only that section's tests run. No model call;
12//! the verdict is never changed.
13
14use std::path::{Path, PathBuf};
15
16use speccoder_core::collection::Toolchain;
17use speccoder_core::regression::ImplementedBy;
18use speccoder_core::verify::{CheckStatus, RedClass, classify_red};
19
20use crate::git::Git;
21use crate::sandbox;
22use crate::verify::process::{CommandRunner, Invocation, OUTPUT_CAP, scrubbed_env};
23
24/// What the search needs.
25pub struct Search<'a> {
26    /// The feature worktree (its `HEAD` is the branch before this tick).
27    pub repo: &'a Path,
28    /// The spec project's directory inside it (`""` at the root).
29    pub prefix: &'a str,
30    /// The feature: its ticks carry `Spec-Task: <feature>/…`.
31    pub feature: &'a str,
32    /// The task searched for (names its scratch directory).
33    pub task: &'a str,
34    /// The acceptance files (project-relative) and their contents.
35    pub files: &'a [(String, String)],
36    /// The command that runs only the task's own tests (a section's,
37    /// when the story's file was appended to).
38    pub argv: &'a [String],
39    /// Where scratch worktrees go (`<state>/tmp`).
40    pub scratch: &'a Path,
41    /// The sandbox configuration the runs are confined by.
42    pub sandbox: &'a sandbox::Config,
43    /// The deadline of one run (the checks').
44    pub timeout: std::time::Duration,
45}
46
47/// The search's answer and the runs it took.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Found {
50    /// The implementing tick, `before_feature`, or `None`.
51    pub implemented_by: Option<ImplementedBy>,
52    /// Acceptance runs made.
53    pub runs: u32,
54}
55
56/// What one run at a commit said.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Probe {
59    /// The tests passed.
60    Pass,
61    /// They built and failed (an assertion, a build error, an absent symbol).
62    Fail,
63    /// Nothing can be said (no worktree, no sandbox, a timeout, output not read).
64    Undetermined,
65}
66
67/// The scratch worktrees' name prefix (pruned at run start).
68const PREFIX: &str = "bisect-";
69
70/// The run budget for `n` ticks: `min(5, ceil(log2(n + 1)) + 1)`.
71#[must_use]
72pub fn budget(n: usize) -> u32 {
73    let mut bits = 0u32;
74    while (1usize << bits) < n + 1 {
75        bits += 1;
76    }
77    (bits + 1).min(5)
78}
79
80/// The command that runs only the tests `added` declares: `argv` with the
81/// runner's own filter (`None`: no filter is known for the runner — the
82/// search then cannot run only the section).
83#[must_use]
84pub fn section_command(argv: &[String], path: &str, added: &str) -> Option<Vec<String>> {
85    let t = speccoder_core::collection::toolchain_of(path)?;
86    let code = speccoder_core::collection::strip_comments(added);
87    let after = |kw: &str| -> Vec<String> {
88        code.lines()
89            .filter_map(|l| {
90                let l = l.trim_start();
91                let rest = l
92                    .strip_prefix("pub ")
93                    .unwrap_or(l)
94                    .strip_prefix("async ")
95                    .unwrap_or_else(|| l.strip_prefix("pub ").unwrap_or(l));
96                let rest = rest.strip_prefix(kw)?;
97                let name: String = rest
98                    .chars()
99                    .take_while(|c| c.is_alphanumeric() || *c == '_')
100                    .collect();
101                (!name.is_empty()).then_some(name)
102            })
103            .collect()
104    };
105    let mut out = argv.to_vec();
106    match t {
107        Toolchain::Rust => {
108            let names = after("fn ");
109            if names.is_empty() {
110                return None;
111            }
112            if !out.iter().any(|a| a == "--") {
113                out.push("--".to_owned());
114            }
115            out.extend(names);
116            out.push("--exact".to_owned());
117        }
118        Toolchain::Python => {
119            let names: Vec<String> = after("def ")
120                .into_iter()
121                .filter(|n| n.starts_with("test"))
122                .collect();
123            if names.is_empty() {
124                return None;
125            }
126            out.extend(["-k".to_owned(), names.join(" or ")]);
127        }
128        Toolchain::Go => {
129            let names = speccoder_core::collection::go_declared_tests(added);
130            if names.is_empty() {
131                return None;
132            }
133            out.extend(["-run".to_owned(), format!("^({})$", names.join("|"))]);
134        }
135        // A test's title is free text: no filter speccoder can trust.
136        Toolchain::JavaScript => return None,
137    }
138    Some(out)
139}
140
141/// The feature's tick commits on the branch at `repo`'s `HEAD`, oldest
142/// first (first-parent), and the commit before the first of them.
143fn ticks(repo: &Path, feature: &str) -> (Vec<(String, String)>, Option<String>) {
144    let log = Git::at(repo)
145        .run(&[
146            "log",
147            "--first-parent",
148            "--reverse",
149            "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1e",
150            "HEAD",
151        ])
152        .map(|b| String::from_utf8_lossy(&b).into_owned())
153        .unwrap_or_default();
154    let ticks: Vec<(String, String)> = log
155        .split('\u{1e}')
156        .filter_map(|r| {
157            let (commit, task) = r.trim().split_once('\u{1f}')?;
158            let task = task.trim().strip_prefix(&format!("{feature}/"))?;
159            Some((commit.to_owned(), task.to_owned()))
160        })
161        .collect();
162    let base = ticks.first().and_then(|(c, _)| {
163        Git::at(repo)
164            .line(&["rev-parse", "--verify", "-q", &format!("{c}^")])
165            .ok()
166            .map(|l| l.trim().to_owned())
167    });
168    (ticks, base)
169}
170
171/// One run at `commit`: its tree in a scratch worktree of this search's
172/// own, the acceptance files overlaid, the command sandboxed there.
173fn probe_at(s: &Search<'_>, commit: &str) -> Probe {
174    if std::fs::create_dir_all(s.scratch).is_err() {
175        return Probe::Undetermined;
176    }
177    let safe: String = s.task.chars().filter(char::is_ascii_alphanumeric).collect();
178    let Ok(holder) = tempfile::Builder::new()
179        .prefix(&format!("{PREFIX}{}-{safe}-", std::process::id()))
180        .tempdir_in(s.scratch)
181    else {
182        return Probe::Undetermined;
183    };
184    let dir = holder.path().join("wt");
185    let Some(path) = dir.to_str() else {
186        return Probe::Undetermined;
187    };
188    let git = Git::at(s.repo);
189    // Worktrees of one repository are added and removed one at a time.
190    let added = {
191        let _setup = super::lanes::LANE_SETUP.lock();
192        git.run(&["worktree", "add", "--detach", "-q", path, commit])
193            .is_ok()
194    };
195    if !added {
196        return Probe::Undetermined;
197    }
198    let project = if s.prefix.is_empty() {
199        dir.clone()
200    } else {
201        dir.join(s.prefix)
202    };
203    let overlaid = s.files.iter().all(|(rel, content)| {
204        let f = project.join(rel);
205        f.parent()
206            .is_none_or(|p| std::fs::create_dir_all(p).is_ok())
207            && std::fs::write(&f, content).is_ok()
208    });
209    let probe = if !overlaid {
210        Probe::Undetermined
211    } else {
212        match sandbox::runner(&dir, s.sandbox) {
213            Err(_) => Probe::Undetermined,
214            Ok((runner, _)) => {
215                let out = runner.run(&Invocation {
216                    argv: s.argv.to_vec(),
217                    cwd: project.clone(),
218                    env: scrubbed_env(std::env::vars()),
219                    timeout: s.timeout,
220                    output_cap: OUTPUT_CAP,
221                });
222                let status = if out.spawn_error.is_some() {
223                    CheckStatus::Error
224                } else if out.timed_out {
225                    CheckStatus::TimedOut
226                } else if out.exit_code == Some(0) {
227                    CheckStatus::Passed
228                } else {
229                    CheckStatus::Failed
230                };
231                let summary = crate::verify::summary::parse(
232                    &String::from_utf8_lossy(&out.stdout.kept),
233                    &String::from_utf8_lossy(&out.stderr.kept),
234                    out.exit_code,
235                );
236                let result = speccoder_core::verify::CheckResult {
237                    name: "acceptance".to_owned(),
238                    argv: s.argv.to_vec(),
239                    status,
240                    exit_code: out.exit_code,
241                    duration_ms: 0,
242                    junit: false,
243                    tests: None,
244                    stdout_tail: String::new(),
245                    stderr_tail: String::new(),
246                    truncated: false,
247                    note: None,
248                    flaky: false,
249                };
250                match (status, classify_red(&result, summary.as_ref())) {
251                    (CheckStatus::Error | CheckStatus::TimedOut, _) => Probe::Undetermined,
252                    (_, RedClass::Passed) => Probe::Pass,
253                    (_, RedClass::Assertion | RedClass::Absent | RedClass::BuildError) => {
254                        Probe::Fail
255                    }
256                    _ => Probe::Undetermined,
257                }
258            }
259        }
260    };
261    {
262        let _setup = super::lanes::LANE_SETUP.lock();
263        let _ = git.run(&["worktree", "remove", "--force", path]);
264    }
265    drop(holder);
266    probe
267}
268
269/// Searches the feature's ticks for the first one at which the acceptance
270/// tests pass (they pass on the branch's tip: the red run said so).
271#[must_use]
272pub fn search(s: &Search<'_>) -> Found {
273    let (ticks, base) = ticks(s.repo, s.feature);
274    search_with(&ticks, base.as_deref(), &mut |c, _| probe_at(s, c))
275}
276
277/// [`search`] over `ticks` (oldest first) with `probe(commit, n)` telling
278/// what the tests did at a commit. Any undetermined probe ends the search
279/// with no answer.
280pub fn search_with(
281    ticks: &[(String, String)],
282    base: Option<&str>,
283    probe: &mut dyn FnMut(&str, u32) -> Probe,
284) -> Found {
285    let short = |c: &str| c.chars().take(7).collect::<String>();
286    let none = |runs| Found {
287        implemented_by: None,
288        runs,
289    };
290    if ticks.is_empty() {
291        // Nothing ticked yet: the branch's tip is the feature's base.
292        return Found {
293            implemented_by: Some(ImplementedBy::BeforeFeature {
294                before_feature: true,
295            }),
296            runs: 0,
297        };
298    }
299    let budget = budget(ticks.len());
300    let mut runs = 0u32;
301    let mut seen: Vec<(usize, bool)> = Vec::new();
302    // The tests pass at the last tick (the tip); find the first that passes.
303    let (mut lo, mut hi) = (0usize, ticks.len() - 1);
304    while lo < hi {
305        if runs >= budget {
306            return none(runs);
307        }
308        let mid = lo + (hi - lo) / 2;
309        runs += 1;
310        let ok = match probe(&ticks[mid].0, runs) {
311            Probe::Pass => true,
312            Probe::Fail => false,
313            Probe::Undetermined => return none(runs),
314        };
315        seen.push((mid, ok));
316        if ok {
317            hi = mid;
318        } else {
319            lo = mid + 1;
320        }
321    }
322    // The tick found must itself be seen passing: the tip was only assumed
323    // to (the red run passed there); a scratch run that disagrees is
324    // evidence that is not monotonic — the answer is unknown.
325    if !seen.contains(&(lo, true)) {
326        if runs >= budget {
327            return none(runs);
328        }
329        runs += 1;
330        if probe(&ticks[lo].0, runs) != Probe::Pass {
331            return none(runs);
332        }
333    }
334    if lo == 0 {
335        // The first tick passes: did the tests pass before the feature?
336        let Some(base) = base.filter(|_| runs < budget) else {
337            return none(runs);
338        };
339        runs += 1;
340        match probe(base, runs) {
341            Probe::Pass => {
342                return Found {
343                    implemented_by: Some(ImplementedBy::BeforeFeature {
344                        before_feature: true,
345                    }),
346                    runs,
347                };
348            }
349            Probe::Fail => {}
350            Probe::Undetermined => return none(runs),
351        }
352    }
353    let (commit, task) = &ticks[lo];
354    Found {
355        implemented_by: Some(ImplementedBy::Task {
356            task: task.clone(),
357            commit: short(commit),
358        }),
359        runs,
360    }
361}
362
363/// Whether a process with `pid` still runs.
364fn alive(pid: u32) -> bool {
365    i32::try_from(pid).is_ok_and(|p| {
366        // Signal 0: delivered to nobody, refused only for a pid that is gone.
367        !matches!(
368            nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None),
369            Err(nix::errno::Errno::ESRCH)
370        )
371    })
372}
373
374/// Removes the scratch worktrees a crashed search left behind: only those
375/// whose process is gone, each by its own `git worktree remove` — never
376/// another live search's, never the repository's other worktrees.
377pub fn prune(repo: &Path, scratch: &Path) {
378    let leftover: Vec<PathBuf> = std::fs::read_dir(scratch)
379        .into_iter()
380        .flatten()
381        .flatten()
382        .filter_map(|e| {
383            let name = e.file_name().to_string_lossy().into_owned();
384            let pid: u32 = name.strip_prefix(PREFIX)?.split('-').next()?.parse().ok()?;
385            (!alive(pid)).then(|| e.path())
386        })
387        .collect();
388    for d in &leftover {
389        let wt = d.join("wt");
390        if let Some(p) = wt.to_str() {
391            let _setup = super::lanes::LANE_SETUP.lock();
392            let _ = Git::at(repo).run(&["worktree", "remove", "--force", p]);
393        }
394        let _ = std::fs::remove_dir_all(d);
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn ticks(n: usize) -> Vec<(String, String)> {
403        (1..=n)
404            .map(|i| (format!("{i:07}abcdef"), format!("T{i:03}")))
405            .collect()
406    }
407
408    fn of(pass: bool) -> Probe {
409        if pass { Probe::Pass } else { Probe::Fail }
410    }
411
412    #[test]
413    fn the_budget_grows_with_the_log_and_stops_at_five() {
414        assert_eq!(budget(1), 2);
415        assert_eq!(budget(3), 3);
416        assert_eq!(budget(6), 4);
417        assert_eq!(budget(14), 5);
418        assert_eq!(budget(1000), 5);
419    }
420
421    #[test]
422    fn the_first_passing_tick_is_found_within_budget() {
423        let t = ticks(6);
424        let mut calls = 0;
425        let found = search_with(&t, Some("base"), &mut |c, _| {
426            calls += 1;
427            of(c >= t[2].0.as_str())
428        });
429        assert_eq!(
430            found.implemented_by,
431            Some(ImplementedBy::Task {
432                task: "T003".to_owned(),
433                commit: "0000003".to_owned()
434            })
435        );
436        assert!(found.runs <= budget(6) && calls == found.runs as usize);
437    }
438
439    #[test]
440    fn passing_at_the_base_is_before_the_feature_and_no_ticks_is_too() {
441        let t = ticks(6);
442        let found = search_with(&t, Some("base"), &mut |_, _| Probe::Pass);
443        assert_eq!(
444            found.implemented_by,
445            Some(ImplementedBy::BeforeFeature {
446                before_feature: true
447            })
448        );
449        assert!(found.runs <= 5);
450        assert_eq!(search_with(&[], None, &mut |_, _| panic!("no run")).runs, 0);
451    }
452
453    #[test]
454    fn a_failing_base_names_the_first_tick() {
455        let t = ticks(2);
456        let found = search_with(&t, Some("base"), &mut |c, _| of(c != "base"));
457        assert_eq!(
458            found.implemented_by,
459            Some(ImplementedBy::Task {
460                task: "T001".to_owned(),
461                commit: "0000001".to_owned()
462            })
463        );
464    }
465
466    #[test]
467    fn a_tip_that_does_not_pass_where_red_said_it_did_is_unknown() {
468        let t = ticks(6);
469        let found = search_with(&t, Some("base"), &mut |_, _| Probe::Fail);
470        assert_eq!(found.implemented_by, None);
471        assert!(found.runs <= budget(6));
472    }
473
474    /// H4: a probe that could not run never moves the search to a later task.
475    #[test]
476    fn an_undetermined_probe_ends_the_search_with_no_name() {
477        let t = ticks(6);
478        // Passing from T002, but the first probe's worktree could not be made.
479        let found = search_with(&t, Some("base"), &mut |c, n| {
480            if n == 1 {
481                Probe::Undetermined
482            } else {
483                of(c >= t[1].0.as_str())
484            }
485        });
486        assert_eq!(found.implemented_by, None);
487        let found = search_with(&ticks(1), Some("base"), &mut |c, _| {
488            if c == "base" {
489                Probe::Undetermined
490            } else {
491                Probe::Pass
492            }
493        });
494        assert_eq!(
495            found.implemented_by, None,
496            "an unreadable base is not 'implemented by T001'"
497        );
498    }
499
500    #[test]
501    fn an_exhausted_budget_or_no_base_cannot_decide() {
502        let t = ticks(1);
503        assert_eq!(
504            search_with(&t, None, &mut |_, _| Probe::Pass).implemented_by,
505            None
506        );
507        let t = ticks(30);
508        let found = search_with(&t, Some("base"), &mut |_, n| of(n % 2 == 0));
509        assert!(found.runs <= 5);
510    }
511
512    /// H6: a section of the story's file runs alone.
513    #[test]
514    fn a_section_runs_only_its_own_tests() {
515        let argv: Vec<String> = ["cargo", "test", "--test", "acceptance_us1"]
516            .map(str::to_owned)
517            .to_vec();
518        assert_eq!(
519            section_command(
520                &argv,
521                "tests/acceptance_us1.rs",
522                "// T006\n#[test]\nfn t006_a() {}\n#[test]\nasync fn t006_b() {}\n"
523            )
524            .unwrap(),
525            [
526                "cargo",
527                "test",
528                "--test",
529                "acceptance_us1",
530                "--",
531                "t006_a",
532                "t006_b",
533                "--exact"
534            ]
535        );
536        let py: Vec<String> = ["python3", "-m", "pytest", "tests/test_acceptance_us1.py"]
537            .map(str::to_owned)
538            .to_vec();
539        assert_eq!(
540            section_command(
541                &py,
542                "tests/test_acceptance_us1.py",
543                "def test_t006_a():\n    pass\n"
544            )
545            .unwrap()
546            .last()
547            .unwrap(),
548            "test_t006_a"
549        );
550        assert_eq!(
551            section_command(&argv, "src/acceptance-us1.test.ts", "it('a', () => {})"),
552            None
553        );
554    }
555
556    /// H5: only a gone process's scratch is pruned.
557    #[test]
558    fn prune_leaves_a_live_search_s_scratch() {
559        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
560        let live = d
561            .path()
562            .join(format!("{PREFIX}{}-T005-x", std::process::id()));
563        let dead = d.path().join(format!("{PREFIX}{}-T006-y", u32::MAX - 7));
564        std::fs::create_dir_all(&live).unwrap_or_else(|e| panic!("{e}"));
565        std::fs::create_dir_all(&dead).unwrap_or_else(|e| panic!("{e}"));
566        prune(d.path(), d.path());
567        assert!(live.exists() && !dead.exists());
568    }
569}