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    if !s.worktree.prepare || !crate::prepare::needed(project) {
289        return Some(runner);
290    }
291    crate::prepare::prepare(&crate::prepare::Prepare {
292        worktree: dir,
293        project,
294        config: s.worktree,
295        runner: &runner,
296        state: s.state,
297    })
298    .ok()?;
299    Some(runner)
300}
301
302/// Searches the feature's ticks for the first one at which the acceptance
303/// tests pass (they pass on the branch's tip: the red run said so).
304#[must_use]
305pub fn search(s: &Search<'_>) -> Found {
306    let (ticks, base) = ticks(s.repo, s.feature);
307    search_with(&ticks, base.as_deref(), &mut |c, _| probe_at(s, c))
308}
309
310/// [`search`] over `ticks` (oldest first) with `probe(commit, n)` telling
311/// what the tests did at a commit. Any undetermined probe ends the search
312/// with no answer.
313pub fn search_with(
314    ticks: &[(String, String)],
315    base: Option<&str>,
316    probe: &mut dyn FnMut(&str, u32) -> Probe,
317) -> Found {
318    let short = |c: &str| c.chars().take(7).collect::<String>();
319    let none = |runs| Found {
320        implemented_by: None,
321        runs,
322    };
323    if ticks.is_empty() {
324        // Nothing ticked yet: the branch's tip is the feature's base.
325        return Found {
326            implemented_by: Some(ImplementedBy::BeforeFeature {
327                before_feature: true,
328            }),
329            runs: 0,
330        };
331    }
332    let budget = budget(ticks.len());
333    let mut runs = 0u32;
334    let mut seen: Vec<(usize, bool)> = Vec::new();
335    // The tests pass at the last tick (the tip); find the first that passes.
336    let (mut lo, mut hi) = (0usize, ticks.len() - 1);
337    while lo < hi {
338        if runs >= budget {
339            return none(runs);
340        }
341        let mid = lo + (hi - lo) / 2;
342        runs += 1;
343        let ok = match probe(&ticks[mid].0, runs) {
344            Probe::Pass => true,
345            Probe::Fail => false,
346            Probe::Undetermined => return none(runs),
347        };
348        seen.push((mid, ok));
349        if ok {
350            hi = mid;
351        } else {
352            lo = mid + 1;
353        }
354    }
355    // The tick found must itself be seen passing: the tip was only assumed
356    // to (the red run passed there); a scratch run that disagrees is
357    // evidence that is not monotonic — the answer is unknown.
358    if !seen.contains(&(lo, true)) {
359        if runs >= budget {
360            return none(runs);
361        }
362        runs += 1;
363        if probe(&ticks[lo].0, runs) != Probe::Pass {
364            return none(runs);
365        }
366    }
367    if lo == 0 {
368        // The first tick passes: did the tests pass before the feature?
369        let Some(base) = base.filter(|_| runs < budget) else {
370            return none(runs);
371        };
372        runs += 1;
373        match probe(base, runs) {
374            Probe::Pass => {
375                return Found {
376                    implemented_by: Some(ImplementedBy::BeforeFeature {
377                        before_feature: true,
378                    }),
379                    runs,
380                };
381            }
382            Probe::Fail => {}
383            Probe::Undetermined => return none(runs),
384        }
385    }
386    let (commit, task) = &ticks[lo];
387    Found {
388        implemented_by: Some(ImplementedBy::Task {
389            task: task.clone(),
390            commit: short(commit),
391        }),
392        runs,
393    }
394}
395
396/// Whether a process with `pid` still runs.
397fn alive(pid: u32) -> bool {
398    i32::try_from(pid).is_ok_and(|p| {
399        // Signal 0: delivered to nobody, refused only for a pid that is gone.
400        !matches!(
401            nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None),
402            Err(nix::errno::Errno::ESRCH)
403        )
404    })
405}
406
407/// Removes the scratch worktrees a crashed search left behind: only those
408/// whose process is gone, each by its own `git worktree remove` — never
409/// another live search's, never the repository's other worktrees.
410pub fn prune(repo: &Path, scratch: &Path) {
411    let leftover: Vec<PathBuf> = std::fs::read_dir(scratch)
412        .into_iter()
413        .flatten()
414        .flatten()
415        .filter_map(|e| {
416            let name = e.file_name().to_string_lossy().into_owned();
417            let pid: u32 = name.strip_prefix(PREFIX)?.split('-').next()?.parse().ok()?;
418            (!alive(pid)).then(|| e.path())
419        })
420        .collect();
421    for d in &leftover {
422        let wt = d.join("wt");
423        crate::prepare::forget(&wt);
424        crate::include::forget(&wt);
425        if let Some(p) = wt.to_str() {
426            let _setup = super::lanes::LANE_SETUP.lock();
427            let _ = Git::at(repo).run(&["worktree", "remove", "--force", p]);
428        }
429        let _ = std::fs::remove_dir_all(d);
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    fn ticks(n: usize) -> Vec<(String, String)> {
438        (1..=n)
439            .map(|i| (format!("{i:07}abcdef"), format!("T{i:03}")))
440            .collect()
441    }
442
443    fn of(pass: bool) -> Probe {
444        if pass { Probe::Pass } else { Probe::Fail }
445    }
446
447    #[test]
448    fn the_budget_grows_with_the_log_and_stops_at_five() {
449        assert_eq!(budget(1), 2);
450        assert_eq!(budget(3), 3);
451        assert_eq!(budget(6), 4);
452        assert_eq!(budget(14), 5);
453        assert_eq!(budget(1000), 5);
454    }
455
456    #[test]
457    fn the_first_passing_tick_is_found_within_budget() {
458        let t = ticks(6);
459        let mut calls = 0;
460        let found = search_with(&t, Some("base"), &mut |c, _| {
461            calls += 1;
462            of(c >= t[2].0.as_str())
463        });
464        assert_eq!(
465            found.implemented_by,
466            Some(ImplementedBy::Task {
467                task: "T003".to_owned(),
468                commit: "0000003".to_owned()
469            })
470        );
471        assert!(found.runs <= budget(6) && calls == found.runs as usize);
472    }
473
474    #[test]
475    fn passing_at_the_base_is_before_the_feature_and_no_ticks_is_too() {
476        let t = ticks(6);
477        let found = search_with(&t, Some("base"), &mut |_, _| Probe::Pass);
478        assert_eq!(
479            found.implemented_by,
480            Some(ImplementedBy::BeforeFeature {
481                before_feature: true
482            })
483        );
484        assert!(found.runs <= 5);
485        assert_eq!(search_with(&[], None, &mut |_, _| panic!("no run")).runs, 0);
486    }
487
488    #[test]
489    fn a_failing_base_names_the_first_tick() {
490        let t = ticks(2);
491        let found = search_with(&t, Some("base"), &mut |c, _| of(c != "base"));
492        assert_eq!(
493            found.implemented_by,
494            Some(ImplementedBy::Task {
495                task: "T001".to_owned(),
496                commit: "0000001".to_owned()
497            })
498        );
499    }
500
501    #[test]
502    fn a_tip_that_does_not_pass_where_red_said_it_did_is_unknown() {
503        let t = ticks(6);
504        let found = search_with(&t, Some("base"), &mut |_, _| Probe::Fail);
505        assert_eq!(found.implemented_by, None);
506        assert!(found.runs <= budget(6));
507    }
508
509    /// H4: a probe that could not run never moves the search to a later task.
510    #[test]
511    fn an_undetermined_probe_ends_the_search_with_no_name() {
512        let t = ticks(6);
513        // Passing from T002, but the first probe's worktree could not be made.
514        let found = search_with(&t, Some("base"), &mut |c, n| {
515            if n == 1 {
516                Probe::Undetermined
517            } else {
518                of(c >= t[1].0.as_str())
519            }
520        });
521        assert_eq!(found.implemented_by, None);
522        let found = search_with(&ticks(1), Some("base"), &mut |c, _| {
523            if c == "base" {
524                Probe::Undetermined
525            } else {
526                Probe::Pass
527            }
528        });
529        assert_eq!(
530            found.implemented_by, None,
531            "an unreadable base is not 'implemented by T001'"
532        );
533    }
534
535    #[test]
536    fn an_exhausted_budget_or_no_base_cannot_decide() {
537        let t = ticks(1);
538        assert_eq!(
539            search_with(&t, None, &mut |_, _| Probe::Pass).implemented_by,
540            None
541        );
542        let t = ticks(30);
543        let found = search_with(&t, Some("base"), &mut |_, n| of(n % 2 == 0));
544        assert!(found.runs <= 5);
545    }
546
547    /// H6: a section of the story's file runs alone.
548    #[test]
549    fn a_section_runs_only_its_own_tests() {
550        let argv: Vec<String> = ["cargo", "test", "--test", "acceptance_us1"]
551            .map(str::to_owned)
552            .to_vec();
553        assert_eq!(
554            section_command(
555                &argv,
556                "tests/acceptance_us1.rs",
557                "// T006\n#[test]\nfn t006_a() {}\n#[test]\nasync fn t006_b() {}\n"
558            )
559            .unwrap(),
560            [
561                "cargo",
562                "test",
563                "--test",
564                "acceptance_us1",
565                "--",
566                "t006_a",
567                "t006_b",
568                "--exact"
569            ]
570        );
571        let py: Vec<String> = ["python3", "-m", "pytest", "tests/test_acceptance_us1.py"]
572            .map(str::to_owned)
573            .to_vec();
574        assert_eq!(
575            section_command(
576                &py,
577                "tests/test_acceptance_us1.py",
578                "def test_t006_a():\n    pass\n"
579            )
580            .unwrap()
581            .last()
582            .unwrap(),
583            "test_t006_a"
584        );
585        assert_eq!(
586            section_command(&argv, "src/acceptance-us1.test.ts", "it('a', () => {})"),
587            None
588        );
589    }
590
591    /// H5: only a gone process's scratch is pruned.
592    #[test]
593    fn prune_leaves_a_live_search_s_scratch() {
594        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
595        let live = d
596            .path()
597            .join(format!("{PREFIX}{}-T005-x", std::process::id()));
598        let dead = d.path().join(format!("{PREFIX}{}-T006-y", u32::MAX - 7));
599        std::fs::create_dir_all(&live).unwrap_or_else(|e| panic!("{e}"));
600        std::fs::create_dir_all(&dead).unwrap_or_else(|e| panic!("{e}"));
601        prune(d.path(), d.path());
602        assert!(live.exists() && !dead.exists());
603    }
604}