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