Skip to main content

speccoder_runtime/pipeline/
bug.rs

1//! A bug task inside the pipeline. Before the attempt: reproduce — one
2//! test per *Expected* statement, red on the untouched code by an assertion
3//! twice, held-out variants sealed — and the person's one confirmation;
4//! the confirmed reproductions are then reused at the checkpoint as the
5//! visible acceptance set. After an attempt that passed: every reproduction
6//! green, the pass-to-pass set green by name, the revert rejected, and each
7//! special case rejected. The rules are `speccoder_core::bug`'s.
8
9use std::path::Path;
10use std::time::Instant;
11
12use serde_json::{Value, json};
13use speccoder_core::bug::{
14    self as rules, Basis, BugReport, Final, HeldOutVariant, Outcome, PassToPass, Reproduce,
15    ReproduceTry, Reproduction, Revert, RevertResult, Source, SpecialCase, Stage as BugStage,
16    TryStatement,
17};
18use speccoder_core::context::ContextKind;
19use speccoder_core::stage::{Stage, StageState};
20use speccoder_core::verify::{
21    CheckStatus, HeldOut, RedClass, Verdict, VerifyReport, classify_red, classify_red_one,
22};
23use speccoder_core::{CommandData, CommandResult, CommandStatus};
24
25use super::{AcceptanceSetup, PreparedAcceptance, ReusedTest, bare_report, class_word, stage};
26use crate::approvals::{Approver, ConfirmAnswer, ConfirmRequest, ShownReproduction};
27use crate::bug::{confirm, handoff, state};
28use crate::integrity::sha256_hex;
29use crate::progress::{BugEvent, Event, Progress};
30use crate::tools::policy::{Decision, Policy};
31use crate::{held_out, sandbox, substrate, tools, worktree};
32
33/// What a bug task's stages need from the run.
34pub(crate) struct Ctx<'a> {
35    pub(crate) rt: &'a tokio::runtime::Runtime,
36    pub(crate) plane: &'a substrate::Plane,
37    pub(crate) project: &'a Path,
38    pub(crate) wt_root: &'a Path,
39    pub(crate) wt_path: &'a str,
40    pub(crate) tmp: &'a Path,
41    pub(crate) policy: &'a Policy,
42    pub(crate) runner: &'a sandbox::Runner,
43    /// The project's state directory.
44    pub(crate) state: &'a Path,
45    pub(crate) feature: &'a str,
46    pub(crate) task: &'a str,
47    pub(crate) bundle: &'a Value,
48    pub(crate) instructions: &'a [crate::instructions::Instruction],
49    pub(crate) live: &'a dyn Progress,
50    pub(crate) approver: Option<&'a dyn Approver>,
51    /// `[acceptance] held_out`.
52    pub(crate) held_out: bool,
53    /// `[bug] reproduce_tries`.
54    pub(crate) max_tries: u32,
55    /// `[bug] test`: the command that runs one test of a file.
56    pub(crate) bug_test: Option<&'a [String]>,
57    /// The tree the task started from.
58    pub(crate) start: &'a worktree::Checkpoint,
59}
60
61/// A confirmed bug, ready for its fix.
62pub(crate) struct Fix {
63    pub(crate) bug: BugReport,
64    pub(crate) record: speccoder_core::acceptance::PreFrozenAcceptance,
65    pub(crate) files: Vec<(String, String)>,
66}
67
68/// Where a bug task is.
69pub(crate) enum Phase {
70    /// It needs reproductions (again).
71    Reproduce(Box<BugReport>),
72    /// Its reproductions are confirmed.
73    Fix(Box<Fix>),
74}
75
76fn event(live: &dyn Progress, task: &str, step: BugEvent) {
77    live.event(&Event::Bug {
78        task: task.to_owned(),
79        step,
80    });
81}
82
83/// The triad as the bug's home holds it now (in `project`).
84pub(crate) fn triad_now(
85    format: &dyn crate::format::Adapter,
86    project: &Path,
87    bug: &BugReport,
88) -> Result<rules::Triad, crate::format::BugRefusal> {
89    let key = if bug.home.kind.native() {
90        bug.task.trim_start_matches("bug:").to_owned()
91    } else {
92        bug.requirement.clone()
93    };
94    let read = format.read_bug(project, &bug.home, &key)?;
95    let current = read.current.first().cloned().unwrap_or_default();
96    rules::Triad::new(&current, &read.expected, &read.unchanged, sha256_hex)
97        .map_err(|e| crate::format::BugRefusal::new(e.code(), e.message()))
98}
99
100/// Whether `feature`'s `task` is a bug, and where it is. `Err`: its
101/// confirmed set was tampered with, or its triad cannot be read.
102pub(crate) fn phase(
103    plane: &Path,
104    feature: &str,
105    task: &str,
106    project: &Path,
107    format: &dyn crate::format::Adapter,
108) -> Result<Option<Phase>, String> {
109    let Some(mut bug) = state::load(plane, feature, task) else {
110        return Ok(None);
111    };
112    let now =
113        triad_now(format, project, &bug).map_err(|e| format!("{} ({})", e.message, e.code))?;
114    if now.digest != bug.triad.digest {
115        // Reworded: the confirmation and the freeze lapse.
116        confirm::lapse(plane, &mut bug, now)?;
117        return Ok(Some(Phase::Reproduce(Box::new(bug))));
118    }
119    if bug
120        .confirmation
121        .as_ref()
122        .is_some_and(|c| c.current(&bug.triad.digest))
123    {
124        match crate::pre_frozen::load(plane, &bug.unit, &bug.requirement) {
125            Ok(Some((record, files)))
126                if speccoder_core::acceptance::current(&record, &bug.triad.digest) =>
127            {
128                return Ok(Some(Phase::Fix(Box::new(Fix { bug, record, files }))));
129            }
130            Ok(_) => {}
131            Err(t) => return Err(t.0),
132        }
133    }
134    // A finished stop is tried again with a fresh budget.
135    if matches!(
136        bug.outcome,
137        Outcome::CannotReproduce | Outcome::AlreadySatisfied | Outcome::UnchangedNotHolding
138    ) {
139        bug.tries.clear();
140        bug.rejections.clear();
141    }
142    Ok(Some(Phase::Reproduce(Box::new(bug))))
143}
144
145/// The statements as scenario references: what held-out failures name.
146pub(crate) fn scenarios(bug: &BugReport) -> Vec<speccoder_core::spec::ScenarioRef> {
147    bug.triad
148        .expected
149        .iter()
150        .map(|s| speccoder_core::spec::ScenarioRef {
151            id: s.id.clone(),
152            text: s.text.clone(),
153        })
154        .collect()
155}
156
157/// The run's stop for `bug` in `outcome`.
158fn stop(
159    c: &Ctx<'_>,
160    mut bug: BugReport,
161    outcome: Outcome,
162    code: Option<&str>,
163    message: String,
164    began: Instant,
165) -> CommandResult {
166    let _ = worktree::restore(c.wt_root, c.tmp, c.start);
167    bug.outcome = outcome;
168    bug.stage = if outcome == Outcome::AwaitingConfirmation {
169        BugStage::Confirm
170    } else {
171        BugStage::Done
172    };
173    bug.code = code.map(str::to_owned);
174    if matches!(
175        outcome,
176        Outcome::CannotReproduce | Outcome::AlreadySatisfied | Outcome::UnchangedNotHolding
177    ) {
178        let cost = bug
179            .tries
180            .iter()
181            .filter_map(|t| t.usage.as_ref())
182            .fold(None, |acc: Option<Value>, u| {
183                super::add_usage(acc.as_ref(), Some(u))
184            });
185        bug.handoff = Some(handoff::compose(
186            &bug,
187            outcome,
188            u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX),
189            cost,
190            None,
191        ));
192    }
193    let next = handoff::next(&bug, outcome);
194    let _ = state::save(c.state, &bug);
195    event(
196        c.live,
197        c.task,
198        BugEvent::Outcome {
199            outcome: outcome.as_str().to_owned(),
200            verdict: None,
201            code: bug.code.clone(),
202            next: next.iter().map(|n| n.command.clone()).collect(),
203        },
204    );
205    let status = match outcome {
206        Outcome::AlreadySatisfied => CommandStatus::Ok,
207        _ => CommandStatus::NeedsHuman,
208    };
209    let mut report = bare_report(c.feature, c.task, c.wt_path, outcome.as_str());
210    report.reason = Some(message.clone());
211    report.bug = Some(Box::new(bug));
212    c.live.event(&super::finished_event(
213        c.task,
214        &report,
215        outcome.as_str(),
216        false,
217        message.clone(),
218    ));
219    let mut r = CommandResult::new("run", status, &message)
220        .with_next(next)
221        .with_data(CommandData::Run(Box::new(report)));
222    if let Some(code) = code {
223        r = r.with_code(code);
224    }
225    r
226}
227
228/// One authored test of a try.
229struct Authored {
230    path: String,
231    content: String,
232    statement: String,
233    test: String,
234    argv: Vec<String>,
235}
236
237/// Whether `rel` is a file that configures how tests are collected or run
238/// (a `conftest.py`, a runner's configuration): frozen as a reproduction, it
239/// would change every later test run, pass-to-pass and checks included.
240fn configures_collection(rel: &str) -> bool {
241    let name = rel.rsplit('/').next().unwrap_or(rel);
242    matches!(
243        name,
244        "conftest.py"
245            | "__init__.py"
246            | "pytest.ini"
247            | "tox.ini"
248            | "setup.cfg"
249            | "pyproject.toml"
250            | "package.json"
251            | "Cargo.toml"
252            | "build.rs"
253            | "go.mod"
254    ) || [
255        "jest.config",
256        "jest.setup",
257        "vitest.config",
258        "vitest.setup",
259        "vite.config",
260        "vitest.workspace",
261        "karma.conf",
262        ".mocharc",
263        "babel.config",
264        ".babelrc",
265    ]
266    .iter()
267    .any(|p| name.starts_with(p))
268}
269
270/// The author's files, checked: each a new test file, one per wanted
271/// statement, naming its test. The command is speccoder's, never the
272/// author's: the one that runs only that test (`[bug] test` or the
273/// toolchain's rule), and the policy must allow it. `Err`: why the try is
274/// discarded.
275fn validate(
276    answer: &Value,
277    want: &[String],
278    project: &Path,
279    policy: &Policy,
280    template: Option<&[String]>,
281) -> Result<Vec<Authored>, String> {
282    let protected = policy.protected();
283    let mut out: Vec<Authored> = Vec::new();
284    for f in answer["files"].as_array().into_iter().flatten() {
285        let path = f["path"].as_str().unwrap_or_default();
286        let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
287        if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
288            return Err(format!("wrote outside tests (`{rel}`)"));
289        }
290        if configures_collection(&rel) {
291            return Err(format!(
292                "`{rel}` configures how tests run: a reproduction is a test file"
293            ));
294        }
295        if project.join(&rel).exists() || out.iter().any(|a| a.path == rel) {
296            return Err(format!("`{rel}` already exists"));
297        }
298        let statement = f["statement"].as_str().unwrap_or_default().to_owned();
299        if !want.contains(&statement) || out.iter().any(|a| a.statement == statement) {
300            return Err(format!(
301                "`{rel}` cites `{statement}`: one test per statement asked for"
302            ));
303        }
304        let test = f["test"].as_str().unwrap_or_default().trim().to_owned();
305        if test.is_empty() {
306            return Err(format!("`{rel}` names no test"));
307        }
308        // Only that test runs: a command that runs others could be red by
309        // an unrelated failure.
310        let argv = crate::bug::named::argv(project, &rel, &test, template).ok_or_else(|| {
311            format!(
312                "speccoder has no rule to run only `{test}` of `{rel}`; set `[bug] test` in speccoder.toml"
313            )
314        })?;
315        if policy.classify("run_command", &json!({ "argv": argv }), None) != Decision::Allow {
316            return Err(format!("`{}` is not an allowed command", argv.join(" ")));
317        }
318        out.push(Authored {
319            path: rel,
320            content: f["content"].as_str().unwrap_or_default().to_owned(),
321            statement,
322            test,
323            argv,
324        });
325    }
326    if let Some(missing) = want
327        .iter()
328        .find(|w| !out.iter().any(|a| a.statement == **w))
329    {
330        return Err(format!("no test for {missing}"));
331    }
332    Ok(out)
333}
334
335/// The class and excerpt of one reproduction on the tree as it is: red
336/// only by an assertion of `test` itself, twice; an *Unchanged* test runs
337/// once and must pass.
338fn baseline_class(
339    c: &Ctx<'_>,
340    argv: &[String],
341    test: &str,
342    reproduces: bool,
343) -> (Vec<RedClass>, String) {
344    let (first, summary) = super::acceptance_run(c.project, argv, c.runner);
345    let one = classify_red_one(&first, summary.as_ref(), test);
346    let excerpt = handoff::cut(&format!("{}\n{}", first.stdout_tail, first.stderr_tail), 20);
347    if !reproduces || one != RedClass::Assertion {
348        return (vec![one], excerpt);
349    }
350    let (second, summary) = super::acceptance_run(c.project, argv, c.runner);
351    let two = classify_red_one(&second, summary.as_ref(), test);
352    (vec![one, rules::bug_class(one, Some(two))], excerpt)
353}
354
355/// The context manifest of an author call: the triad (the person's), the
356/// plan and constitution, the task, rejections and the issue.
357fn manifest(
358    c: &Ctx<'_>,
359    bug: &BugReport,
360    issue: Option<&state::IssueText>,
361) -> speccoder_core::context::ContextManifest {
362    let mut m = crate::context::Manifest::new();
363    m.push(
364        ContextKind::Story,
365        "person",
366        "/triad",
367        bug.triad.canonical().as_bytes(),
368    )
369    .push(
370        ContextKind::Constitution,
371        "worktree",
372        "/bundle/constitution",
373        c.bundle["constitution"]
374            .as_str()
375            .unwrap_or_default()
376            .as_bytes(),
377    )
378    .push(
379        ContextKind::Plan,
380        "worktree",
381        "/bundle/plan",
382        c.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
383    )
384    .push(
385        ContextKind::Task,
386        "worktree",
387        "/bundle/task",
388        c.bundle["task"].to_string().as_bytes(),
389    );
390    for (i, r) in bug.rejections.iter().enumerate() {
391        m.push(
392            ContextKind::Rejection,
393            "person",
394            &format!("/rejections/{i}"),
395            r.reason.as_bytes(),
396        );
397    }
398    if let (Some(i), Some(issue)) = (bug.issue.as_ref(), issue) {
399        m.push(
400            ContextKind::Issue,
401            &format!("github:{}#{}", i.repo.clone().unwrap_or_default(), i.number),
402            &format!("#{}", i.number),
403            format!("{}\n{}", issue.title, issue.body).as_bytes(),
404        );
405    }
406    m.finish()
407}
408
409/// The project's files and manifest, as an author is given them.
410fn project_view(c: &Ctx<'_>) -> (Vec<String>, String) {
411    let setup = AcceptanceSetup {
412        rt: c.rt,
413        plane: c.plane,
414        project: c.project,
415        worktree: c.wt_root,
416        tmp: c.tmp,
417        policy: c.policy,
418        feature: c.feature,
419        task: c.task,
420        bundle: c.bundle.clone(),
421        scenarios: &[],
422        instructions: c.instructions,
423        runner: c.runner,
424    };
425    let files: Vec<String> = worktree::checkpoint(setup.worktree, setup.tmp)
426        .and_then(|cp| worktree::files(setup.worktree, &cp))
427        .unwrap_or_default()
428        .into_iter()
429        .take(400)
430        .collect();
431    let manifest = super::MANIFESTS
432        .iter()
433        .find_map(|m| {
434            std::fs::read_to_string(c.project.join(m))
435                .ok()
436                .map(|t| format!("{m}:\n{t}"))
437        })
438        .map(|t| t.chars().take(8192).collect::<String>())
439        .unwrap_or_default();
440    (files, manifest)
441}
442
443/// One author call; `(answer, run id, usage, notes)`.
444fn author(
445    c: &Ctx<'_>,
446    input: agentplane::core::Tainted<Value>,
447) -> Result<(Value, String, Option<Value>, Option<String>), String> {
448    let outcome =
449        c.rt.block_on(c.plane.acceptance(c.feature, c.task, input))
450            .map_err(|e| format!("the author call failed: {}", e.0))?;
451    let output = outcome
452        .output
453        .as_ref()
454        .map(|o| o.peek().clone())
455        .unwrap_or_default();
456    let answer = output["answer"].clone();
457    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
458        return Err(format!(
459            "the author did not answer ({})",
460            outcome.status.as_str()
461        ));
462    }
463    let notes = answer["notes"].as_array().map(|n| {
464        n.iter()
465            .filter_map(Value::as_str)
466            .collect::<Vec<_>>()
467            .join(" ")
468    });
469    Ok((
470        answer,
471        outcome.run_id.to_string(),
472        Some(output["usage"].clone()).filter(|u| !u.is_null()),
473        notes,
474    ))
475}
476
477/// Reproduce: tries until every *Expected* statement is red by an
478/// assertion (or the tries run out), the held-out variants sealed, then the
479/// confirmation. `Ok`: confirmed (inline, or by naming the tests) — the fix
480/// runs now; `Err`: the run stops with the bug's outcome.
481#[allow(clippy::too_many_lines)] // one linear procedure, in reading order
482pub(crate) fn reproduce(c: &Ctx<'_>, mut bug: BugReport) -> Result<Fix, Box<CommandResult>> {
483    let began = Instant::now();
484    stage(
485        c.live,
486        c.task,
487        Stage::Reproduce,
488        StageState::Started,
489        None,
490        None,
491    );
492    bug.stage = BugStage::Reproduce;
493    bug.max_tries = c.max_tries;
494    let extra = state::extra(c.state, &bug.unit, &bug.task);
495    let expected = bug.expected_ids();
496    let unchanged: Vec<String> = bug.triad.unchanged.iter().map(|s| s.id.clone()).collect();
497    let person: Vec<Reproduction> = bug
498        .reproductions
499        .iter()
500        .filter(|r| r.source == Source::Person)
501        .cloned()
502        .collect();
503    // The person's tests stand for the Expected statements; the author
504    // writes the rest.
505    let want: Vec<String> = if person.is_empty() {
506        expected.iter().chain(&unchanged).cloned().collect()
507    } else {
508        unchanged.clone()
509    };
510    let statements = json!(bug.triad.statements().collect::<Vec<_>>());
511    let person_files = state::repro(
512        c.state,
513        &bug.unit,
514        &bug.task,
515        &person.iter().map(|r| r.file.clone()).collect::<Vec<_>>(),
516    );
517    let stop_now = |bug: BugReport, outcome, code, message: String| {
518        stage(
519            c.live,
520            c.task,
521            Stage::Reproduce,
522            if outcome == Outcome::AlreadySatisfied {
523                StageState::Passed
524            } else {
525                StageState::Failed
526            },
527            Some(outcome_word(outcome)),
528            Some(began),
529        );
530        Box::new(stop(c, bug, outcome, code, message, began))
531    };
532    let kept: Vec<(Reproduction, String)> = loop {
533        // Every try counts, a rejected one too: once they are used up the
534        // author is not called again.
535        if u32::try_from(bug.tries.len()).unwrap_or(u32::MAX) >= c.max_tries.max(1) {
536            let saved = state::save(c.state, &bug);
537            match rules::decide_reproduce(&bug.tries, &expected, c.max_tries) {
538                // Still waiting for the person: the same reproductions.
539                Reproduce::Reproduced { .. } if bug.confirm_digest.is_some() && saved.is_ok() => {
540                    let digest = bug.confirm_digest.clone().unwrap_or_default();
541                    let message = format!(
542                        "the reproductions wait for your confirmation (digest {digest}): speccoder bug confirm {} --digest {digest}",
543                        bug.key()
544                    );
545                    return Err(Box::new(stop(
546                        c,
547                        bug,
548                        Outcome::AwaitingConfirmation,
549                        None,
550                        message,
551                        began,
552                    )));
553                }
554                _ => {
555                    let message = format!(
556                        "cannot reproduce: the {} tries are used up ({} rejected); nothing is fixed",
557                        bug.tries.len(),
558                        bug.tries
559                            .iter()
560                            .filter(|t| t
561                                .discarded
562                                .as_deref()
563                                .is_some_and(|d| d.starts_with("rejected")))
564                            .count()
565                    );
566                    return Err(stop_now(
567                        bug,
568                        Outcome::CannotReproduce,
569                        Some("BUG-CANNOT-REPRODUCE"),
570                        message,
571                    ));
572                }
573            }
574        }
575        let n = u32::try_from(bug.tries.len()).unwrap_or(u32::MAX) + 1;
576        let context = manifest(c, &bug, extra.issue.as_ref());
577        let rejections: Vec<String> = bug.rejections.iter().map(|r| r.reason.clone()).collect();
578        let mut t = ReproduceTry {
579            n,
580            per_statement: Vec::new(),
581            discarded: None,
582            run_id: None,
583            usage: None,
584            rejection: rejections.last().cloned(),
585        };
586        let mut this: Vec<(Reproduction, String)> = Vec::new();
587        // The person's tests: run again on this tree.
588        for (r, (_, content)) in person.iter().zip(&person_files) {
589            let full = c.project.join(&r.file);
590            if !full.exists() {
591                if let Some(parent) = full.parent() {
592                    let _ = std::fs::create_dir_all(parent);
593                }
594                let _ = std::fs::write(&full, content);
595            }
596            let (classes, excerpt) = baseline_class(c, &r.argv, &r.test, true);
597            let class = classes.last().copied().unwrap_or(RedClass::Unclassified);
598            t.per_statement.push(TryStatement {
599                statement: r.statement.clone(),
600                class,
601                excerpt: (class != RedClass::Assertion).then_some(excerpt),
602            });
603            let mut r = r.clone();
604            r.baseline = classes;
605            this.push((r, content.clone()));
606        }
607        if !want.is_empty() {
608            let (files, manifest_text) = project_view(c);
609            let input =
610                substrate::acceptance::reproduce_input(&substrate::acceptance::ReproduceParts {
611                    set: "reproduce",
612                    bundle: c.bundle.clone(),
613                    statements: statements.clone(),
614                    want: &want,
615                    files: &files,
616                    manifest: &manifest_text,
617                    visible: &[],
618                    rejections: &rejections,
619                    issue: extra
620                        .issue
621                        .as_ref()
622                        .map(|i| (i.title.as_str(), i.body.as_str())),
623                    instructions: c.instructions,
624                    context: &context,
625                });
626            match author(c, input) {
627                Err(why) => t.discarded = Some(why),
628                Ok((answer, run_id, usage, _notes)) => {
629                    t.run_id = Some(run_id);
630                    t.usage = usage;
631                    match validate(&answer, &want, c.project, c.policy, c.bug_test) {
632                        Err(why) => t.discarded = Some(why),
633                        Ok(files) => {
634                            for f in files {
635                                let full = c.project.join(&f.path);
636                                if let Some(parent) = full.parent() {
637                                    let _ = std::fs::create_dir_all(parent);
638                                }
639                                if std::fs::write(&full, &f.content).is_err() {
640                                    t.discarded =
641                                        Some(format!("`{}` could not be written", f.path));
642                                    break;
643                                }
644                                let reproduces = f.statement.starts_with('E');
645                                let (classes, excerpt) =
646                                    baseline_class(c, &f.argv, &f.test, reproduces);
647                                let class =
648                                    classes.last().copied().unwrap_or(RedClass::Unclassified);
649                                let red_ok = if reproduces {
650                                    class == RedClass::Assertion
651                                } else {
652                                    class == RedClass::Passed
653                                };
654                                t.per_statement.push(TryStatement {
655                                    statement: f.statement.clone(),
656                                    class,
657                                    excerpt: (!red_ok).then_some(excerpt),
658                                });
659                                this.push((
660                                    Reproduction {
661                                        statement: f.statement,
662                                        file: f.path,
663                                        test: f.test,
664                                        source: Source::Author,
665                                        baseline: classes,
666                                        digest: format!(
667                                            "sha256:{}",
668                                            sha256_hex(f.content.as_bytes())
669                                        ),
670                                        argv: f.argv,
671                                        final_: Final::NotRun,
672                                        revert: Revert::NotRun,
673                                    },
674                                    f.content,
675                                ));
676                            }
677                        }
678                    }
679                }
680            }
681        }
682        if t.discarded.is_some() {
683            t.per_statement.clear();
684        }
685        // Nothing of a try stays in the tree.
686        let _ = worktree::restore(c.wt_root, c.tmp, c.start);
687        event(
688            c.live,
689            c.task,
690            BugEvent::ReproduceTry {
691                n,
692                of: c.max_tries,
693                per_statement: t
694                    .per_statement
695                    .iter()
696                    .map(|s| crate::progress::StatementClass {
697                        statement: s.statement.clone(),
698                        class: class_word(s.class),
699                    })
700                    .collect(),
701            },
702        );
703        bug.tries.push(t);
704        if let Err(e) = state::save(c.state, &bug) {
705            return Err(Box::new(stop(
706                c,
707                bug,
708                Outcome::Recorded,
709                None,
710                format!("the try could not be kept: {e}"),
711                began,
712            )));
713        }
714        match rules::decide_reproduce(&bug.tries, &expected, c.max_tries) {
715            Reproduce::Reproduced { .. } => {
716                let last = bug
717                    .tries
718                    .last()
719                    .map(|t| t.per_statement.clone())
720                    .unwrap_or_default();
721                if let Some(u) = last
722                    .iter()
723                    .find(|s| s.statement.starts_with('U') && s.class != RedClass::Passed)
724                {
725                    let words = bug
726                        .triad
727                        .statement(&u.statement)
728                        .map(|s| s.text.clone())
729                        .unwrap_or_default();
730                    return Err(stop_now(
731                        bug,
732                        Outcome::UnchangedNotHolding,
733                        Some("BUG-UNCHANGED-NOT-HOLDING"),
734                        format!(
735                            "{} does not hold today: its test fails on the untouched code ({}) — \"{words}\"; nothing is fixed",
736                            u.statement,
737                            class_word(u.class)
738                        ),
739                    ));
740                }
741                break this;
742            }
743            Reproduce::Retry => {}
744            Reproduce::AlreadySatisfied => {
745                bug.reproductions = person.clone();
746                bug.reproductions
747                    .extend(this.iter().map(|(r, _)| r.clone()));
748                let files: Vec<(String, String)> = this
749                    .iter()
750                    .map(|(r, c)| (r.file.clone(), c.clone()))
751                    .collect();
752                let _ = state::save_repro(c.state, &bug.unit, &bug.task, &files);
753                return Err(stop_now(
754                    bug,
755                    Outcome::AlreadySatisfied,
756                    Some("BUG-ALREADY-SATISFIED"),
757                    format!(
758                        "every reproduction passed on the untouched code on each of {} tries: nothing to fix, nothing ticked",
759                        c.max_tries
760                    ),
761                ));
762            }
763            Reproduce::CannotReproduce {
764                reproduced,
765                satisfied,
766                not_demonstrated,
767            } => {
768                let mut said = vec![format!("after {} tries", bug.tries.len())];
769                if !reproduced.is_empty() {
770                    said.push(format!("reproduced {}", reproduced.join(", ")));
771                }
772                if !satisfied.is_empty() {
773                    said.push(format!(
774                        "{} already pass (narrow the triad)",
775                        satisfied.join(", ")
776                    ));
777                }
778                if !not_demonstrated.is_empty() {
779                    said.push(format!("not reproduced {}", not_demonstrated.join(", ")));
780                }
781                return Err(stop_now(
782                    bug,
783                    Outcome::CannotReproduce,
784                    Some("BUG-CANNOT-REPRODUCE"),
785                    format!("cannot reproduce: {}; nothing is fixed", said.join("; ")),
786                ));
787            }
788        }
789    };
790    stage(
791        c.live,
792        c.task,
793        Stage::Reproduce,
794        StageState::Passed,
795        Some("assertion".to_owned()),
796        Some(began),
797    );
798
799    // Held-out variants: other inputs, the same statements; sealed.
800    bug.held_out.clear();
801    bug.held_out_sealed = None;
802    bug.held_out_reason = None;
803    if c.held_out {
804        let visible: Vec<(String, String)> = kept
805            .iter()
806            .filter(|(r, _)| r.reproduces())
807            .map(|(r, content)| (r.file.clone(), content.clone()))
808            .collect();
809        match seal_variants(c, &mut bug, &visible, &expected, &[]) {
810            Ok(sealed) => bug.held_out_sealed = Some(sealed),
811            Err(why) => bug.held_out_reason = Some(why),
812        }
813    }
814    bug.reproductions = kept.iter().map(|(r, _)| r.clone()).collect();
815    let files: Vec<(String, String)> = kept
816        .iter()
817        .map(|(r, c)| (r.file.clone(), c.clone()))
818        .collect();
819    if let Err(e) = state::save_repro(c.state, &bug.unit, &bug.task, &files) {
820        return Err(Box::new(stop(
821            c,
822            bug,
823            Outcome::Recorded,
824            None,
825            format!("the reproductions could not be kept: {e}"),
826            began,
827        )));
828    }
829    let digest = confirm::digest(&bug.reproductions);
830    bug.confirm_digest = Some(digest.clone());
831    event(
832        c.live,
833        c.task,
834        BugEvent::Reproduced {
835            reproductions: bug
836                .reproductions
837                .iter()
838                .map(|r| crate::progress::ShownReproduction {
839                    statement: r.statement.clone(),
840                    file: r.file.clone(),
841                    test: r.test.clone(),
842                    digest: r.digest.clone(),
843                })
844                .collect(),
845        },
846    );
847    let _ = state::save(c.state, &bug);
848
849    // The person named the tests: that is the confirmation.
850    if !person.is_empty() {
851        let by = extra
852            .by
853            .clone()
854            .unwrap_or_else(|| crate::approvals::who_in(c.project));
855        let via = extra.via.unwrap_or(speccoder_core::actions::Via::Terminal);
856        return freeze(c, bug, &by, via, Basis::NamedTest, began);
857    }
858    stage(
859        c.live,
860        c.task,
861        Stage::Confirm,
862        StageState::Started,
863        None,
864        None,
865    );
866    let request = ConfirmRequest {
867        bug: bug.key(),
868        title: bug.title.clone(),
869        digest: digest.clone(),
870        reproductions: bug
871            .reproductions
872            .iter()
873            .zip(&files)
874            .map(|(r, (_, content))| ShownReproduction {
875                statement: r.statement.clone(),
876                words: bug
877                    .triad
878                    .statement(&r.statement)
879                    .map(|s| s.text.clone())
880                    .unwrap_or_default(),
881                file: r.file.clone(),
882                test: r.test.clone(),
883                argv: r.argv.clone(),
884                excerpt: bug
885                    .tries
886                    .last()
887                    .and_then(|t| t.per_statement.iter().find(|s| s.statement == r.statement))
888                    .and_then(|s| s.excerpt.clone())
889                    .unwrap_or_default(),
890                content: content.clone(),
891            })
892            .collect(),
893        warning: crate::bug::held_out_warning(&bug),
894    };
895    match c
896        .approver
897        .and_then(|a| a.confirm(&request).map(|ans| (ans, a.via())))
898    {
899        Some((ConfirmAnswer::Confirm, via)) => {
900            let by = crate::approvals::who_in(c.project);
901            freeze(c, bug, &by, via, Basis::Shown, began)
902        }
903        Some((ConfirmAnswer::Reject(reason), via)) => {
904            if let Err(e) = reject(c.state, &mut bug, &reason, via) {
905                return Err(Box::new(stop(
906                    c,
907                    bug,
908                    Outcome::Recorded,
909                    None,
910                    format!("the rejection could not be kept: {e}"),
911                    began,
912                )));
913            }
914            event(c.live, c.task, BugEvent::ConfirmRejected { digest, reason });
915            stage(
916                c.live,
917                c.task,
918                Stage::Confirm,
919                StageState::Failed,
920                Some("rejected".to_owned()),
921                None,
922            );
923            if u32::try_from(bug.tries.len()).unwrap_or(u32::MAX) >= c.max_tries {
924                let message = format!(
925                    "cannot reproduce: the reproductions of {} tries were not accepted; nothing is fixed",
926                    bug.tries.len()
927                );
928                return Err(stop_now(
929                    bug,
930                    Outcome::CannotReproduce,
931                    Some("BUG-CANNOT-REPRODUCE"),
932                    message,
933                ));
934            }
935            reproduce(c, bug)
936        }
937        None => {
938            let next = format!("speccoder bug confirm {} --digest {digest}", bug.key());
939            event(
940                c.live,
941                c.task,
942                BugEvent::ConfirmWaiting {
943                    digest: digest.clone(),
944                    next: next.clone(),
945                },
946            );
947            stage(
948                c.live,
949                c.task,
950                Stage::Confirm,
951                StageState::Started,
952                Some("waits for you".to_owned()),
953                None,
954            );
955            let message = format!(
956                "{} reproduction(s) fail on the untouched code by an assertion; they wait for your confirmation (digest {digest}): {next}{}",
957                bug.reproductions.iter().filter(|r| r.reproduces()).count(),
958                crate::bug::held_out_warning(&bug)
959                    .map(|w| format!(" — {w}"))
960                    .unwrap_or_default()
961            );
962            Err(Box::new(stop(
963                c,
964                bug,
965                Outcome::AwaitingConfirmation,
966                None,
967                message,
968                began,
969            )))
970        }
971    }
972}
973
974fn outcome_word(o: Outcome) -> String {
975    o.as_str().replace('_', " ")
976}
977
978/// Records a rejection: the reason goes to the next try, and the try that
979/// wrote what was rejected is discarded (it never reproduces again; it
980/// still counts against the tries). `Err`: it could not be kept.
981pub(crate) fn reject(
982    state_dir: &Path,
983    bug: &mut BugReport,
984    reason: &str,
985    via: speccoder_core::actions::Via,
986) -> Result<(), String> {
987    if let Some(t) = bug.tries.last_mut() {
988        t.discarded = Some(rules::rejected_try(reason));
989    }
990    bug.rejections.push(rules::Rejection {
991        digest: bug.confirm_digest.clone().unwrap_or_default(),
992        reason: reason.to_owned(),
993        by: crate::approvals::who(),
994        via,
995        at: confirm::now(),
996    });
997    bug.confirm_digest = None;
998    bug.outcome = Outcome::Recorded;
999    bug.stage = BugStage::Reproduce;
1000    state::save(state_dir, bug)
1001}
1002
1003fn freeze(
1004    c: &Ctx<'_>,
1005    mut bug: BugReport,
1006    by: &str,
1007    via: speccoder_core::actions::Via,
1008    basis: Basis,
1009    began: Instant,
1010) -> Result<Fix, Box<CommandResult>> {
1011    match confirm::freeze(c.state, &mut bug, by, via, basis) {
1012        Ok((record, files)) => {
1013            event(
1014                c.live,
1015                c.task,
1016                BugEvent::Confirmed {
1017                    digest: record.approval_digest.clone(),
1018                    by: by.to_owned(),
1019                    via: via.as_str().to_owned(),
1020                    basis: if basis == Basis::NamedTest {
1021                        "named_test"
1022                    } else {
1023                        "shown"
1024                    }
1025                    .to_owned(),
1026                },
1027            );
1028            stage(
1029                c.live,
1030                c.task,
1031                Stage::Confirm,
1032                StageState::Passed,
1033                Some(by.to_owned()),
1034                None,
1035            );
1036            Ok(Fix { bug, record, files })
1037        }
1038        Err(e) => Err(Box::new(stop(
1039            c,
1040            bug,
1041            Outcome::Recorded,
1042            None,
1043            format!("the confirmation could not be recorded: {e}"),
1044            began,
1045        ))),
1046    }
1047}
1048
1049/// Writes held-out variants for `statements`, red-checks them on the tree
1050/// as it is (twice, by assertion), and seals the set (keeping variants of
1051/// other statements already sealed). `told`: what a regeneration is told.
1052fn seal_variants(
1053    c: &Ctx<'_>,
1054    bug: &mut BugReport,
1055    visible: &[(String, String)],
1056    statements: &[String],
1057    told: &[String],
1058) -> Result<speccoder_core::acceptance::SealedRef, String> {
1059    let (files, manifest_text) = project_view(c);
1060    let context = manifest(c, bug, None);
1061    let input = substrate::acceptance::reproduce_input(&substrate::acceptance::ReproduceParts {
1062        set: "reproduce_held_out",
1063        bundle: c.bundle.clone(),
1064        statements: json!(bug.triad.statements().collect::<Vec<_>>()),
1065        want: statements,
1066        files: &files,
1067        manifest: &manifest_text,
1068        visible,
1069        rejections: told,
1070        issue: None,
1071        instructions: c.instructions,
1072        context: &context,
1073    });
1074    let (answer, run_id, _, _) =
1075        author(c, input).map_err(|e| format!("the held-out author: {e}"))?;
1076    let protected = c.policy.protected();
1077    let mut kept: Vec<(held_out::HeldOutFile, String)> = Vec::new();
1078    let mut discarded = Vec::new();
1079    for f in answer["files"].as_array().into_iter().flatten() {
1080        let path = f["path"].as_str().unwrap_or_default();
1081        let content = f["content"].as_str().unwrap_or_default();
1082        let cited: Vec<String> = f["scenarios"]
1083            .as_array()
1084            .into_iter()
1085            .flatten()
1086            .filter_map(|s| s.as_str().map(str::to_owned))
1087            .collect();
1088        let argv: Vec<String> = f["argv"]
1089            .as_array()
1090            .into_iter()
1091            .flatten()
1092            .filter_map(|s| s.as_str().map(str::to_owned))
1093            .collect();
1094        let Ok(rel) = tools::paths::normalise(path) else {
1095            discarded.push(held_out::Discarded {
1096                path: path.to_owned(),
1097                reason: "path".to_owned(),
1098            });
1099            continue;
1100        };
1101        let ok_path = tools::paths::is_test_path(&rel)
1102            && protected.write_class(&rel).is_none()
1103            && !c.project.join(&rel).exists()
1104            && !visible.iter().any(|(p, _)| *p == rel);
1105        let ok_cites = cited.len() == 1
1106            && statements.contains(&cited[0])
1107            && !kept.iter().any(|(k, _)| k.scenarios == cited);
1108        let ok_argv = c
1109            .policy
1110            .classify("run_command", &json!({ "argv": argv }), None)
1111            == Decision::Allow;
1112        if !(ok_path && ok_cites && ok_argv) {
1113            discarded.push(held_out::Discarded {
1114                path: rel,
1115                reason: if ok_path {
1116                    if ok_cites {
1117                        "command"
1118                    } else {
1119                        "unknown_scenario"
1120                    }
1121                } else {
1122                    "path"
1123                }
1124                .to_owned(),
1125            });
1126            continue;
1127        }
1128        kept.push((
1129            held_out::HeldOutFile {
1130                path: rel,
1131                sha256: sha256_hex(content.as_bytes()),
1132                scenarios: cited,
1133                argv,
1134                composition: false,
1135            },
1136            content.to_owned(),
1137        ));
1138    }
1139    // Red twice on the tree as it is, by an assertion.
1140    let mut red: Vec<(held_out::HeldOutFile, String)> = Vec::new();
1141    // Red by what each variant adds: a failure its command shows without it
1142    // is another test's.
1143    let once = held_out::overlay_red(c.wt_root, c.project, c.tmp, &kept, c.runner, c.policy)
1144        .map_err(|e| format!("the held-out variants could not be run: {e}"))?;
1145    let twice = held_out::overlay_red(c.wt_root, c.project, c.tmp, &kept, c.runner, c.policy)
1146        .map_err(|e| format!("the held-out variants could not be run: {e}"))?;
1147    for ((f, content), (a, b)) in kept.into_iter().zip(once.iter().zip(&twice)) {
1148        let class = held_out::red_class;
1149        let c2 = rules::bug_class(class(a), Some(class(b)));
1150        bug.held_out.retain(|h| !f.scenarios.contains(&h.statement));
1151        bug.held_out.push(HeldOutVariant {
1152            statement: f.scenarios[0].clone(),
1153            baseline: c2,
1154            final_: Final::NotRun,
1155        });
1156        if c2 == RedClass::Assertion {
1157            red.push((f, content));
1158        } else {
1159            discarded.push(held_out::Discarded {
1160                path: f.path,
1161                reason: "not_red".to_owned(),
1162            });
1163        }
1164    }
1165    let written = red.len();
1166    // Variants of statements not written again stay.
1167    if let Some(old) = bug.held_out_sealed.clone() {
1168        let (m, contents) = held_out::open_sealed(c.state, &bug.unit, &old)
1169            .map_err(|e| format!("the sealed held-out set could not be opened: {}", e.0))?;
1170        for (f, (_, content)) in m.files.into_iter().zip(contents) {
1171            if !statements.iter().any(|s| f.scenarios.contains(s)) {
1172                red.push((f, content));
1173            }
1174        }
1175    }
1176    if written == 0 {
1177        return Err(if discarded.is_empty() {
1178            "the held-out author wrote no variant".to_owned()
1179        } else {
1180            format!(
1181                "no held-out variant was red on the untouched code by an assertion ({} discarded: {})",
1182                discarded.len(),
1183                discarded
1184                    .iter()
1185                    .map(|d| d.reason.as_str())
1186                    .collect::<Vec<_>>()
1187                    .join(", ")
1188            )
1189        });
1190    }
1191    let checkpoint = worktree::checkpoint(c.wt_root, c.tmp)
1192        .map(|cp| cp.0)
1193        .unwrap_or_default();
1194    let manifest = held_out::Manifest {
1195        version: 1,
1196        feature: bug.unit.clone(),
1197        task: bug.task.clone(),
1198        author_run: run_id,
1199        checkpoint,
1200        visible_scenarios: bug.expected_ids(),
1201        files: red.iter().map(|(f, _)| f.clone()).collect(),
1202        discarded,
1203    };
1204    let contents: Vec<(String, String)> = red
1205        .iter()
1206        .map(|(f, c)| (f.path.clone(), c.clone()))
1207        .collect();
1208    held_out::seal(c.state, &bug.unit, &bug.requirement, &manifest, &contents)
1209        .map_err(|e| format!("the held-out variants could not be sealed: {e}"))
1210}
1211
1212/// The confirmed set at the checkpoint (the one reuse function every
1213/// pre-frozen producer uses), with the bug red rule.
1214pub(crate) fn reuse(c: &Ctx<'_>, fix: &Fix) -> Result<PreparedAcceptance, String> {
1215    let tests: Vec<ReusedTest> = fix
1216        .bug
1217        .reproductions
1218        .iter()
1219        .map(|r| ReusedTest {
1220            path: r.file.clone(),
1221            cites: vec![r.statement.clone()],
1222            argv: r.argv.clone(),
1223            green: !r.reproduces(),
1224        })
1225        .collect();
1226    super::reuse_pre_frozen(
1227        c.project,
1228        &fix.record,
1229        &fix.files,
1230        &tests,
1231        c.runner,
1232        super::RedRule::BugAssertionOnly,
1233        None,
1234    )
1235}
1236
1237/// What judging a bug's attempt decided.
1238pub(crate) struct Judged {
1239    /// The verdict, with the bug rules applied.
1240    pub(crate) verdict: Verdict,
1241    /// A special case survived even its regenerated variant: the person
1242    /// rules (`BUG-REPRO-WEAK`).
1243    pub(crate) weak: Option<speccoder_core::strength::Permitted>,
1244}
1245
1246/// What judging needs besides the context.
1247pub(crate) struct Judging<'a> {
1248    pub(crate) before: &'a worktree::Checkpoint,
1249    pub(crate) baseline: &'a VerifyReport,
1250    pub(crate) verification: Option<&'a VerifyReport>,
1251    pub(crate) base: Verdict,
1252    /// The strength directory (rulings on special cases).
1253    pub(crate) keep: &'a Path,
1254}
1255
1256/// The class of reproduction `r` (its one test) on the tree as it is.
1257fn run_class(c: &Ctx<'_>, r: &Reproduction) -> RedClass {
1258    let (run, summary) = super::acceptance_run(c.project, &r.argv, c.runner);
1259    let class = classify_red_one(&run, summary.as_ref(), &r.test);
1260    if run.status == CheckStatus::Passed && class != RedClass::Unclassified {
1261        RedClass::Passed
1262    } else {
1263        class
1264    }
1265}
1266
1267/// `path` relative to the project, from a repository-relative one.
1268fn in_project(c: &Ctx<'_>, repo_path: &str) -> Option<String> {
1269    let prefix = c
1270        .project
1271        .strip_prefix(c.wt_root)
1272        .map(|p| p.to_string_lossy().replace('\\', "/"))
1273        .unwrap_or_default();
1274    if prefix.is_empty() {
1275        Some(repo_path.to_owned())
1276    } else {
1277        repo_path
1278            .strip_prefix(&format!("{prefix}/"))
1279            .map(str::to_owned)
1280    }
1281}
1282
1283/// Judges a bug task's attempt that passed the ordinary rules: finals,
1284/// pass-to-pass, revert, special cases. `Err`: the tree or a judge input
1285/// did not come back as it was (the measurement is void).
1286#[allow(clippy::too_many_lines)] // one linear procedure, in reading order
1287pub(crate) fn judge(c: &Ctx<'_>, fix: &mut Fix, j: &Judging<'_>) -> Result<Judged, String> {
1288    let began = Instant::now();
1289    stage(
1290        c.live,
1291        c.task,
1292        Stage::Unchanged,
1293        StageState::Started,
1294        None,
1295        None,
1296    );
1297    let fin = worktree::checkpoint(c.wt_root, c.tmp).map_err(|e| e.0)?;
1298    let judged_before = crate::integrity::JudgeSnapshot::take(c.wt_root, c.policy);
1299    // 1. Every reproduction (and Unchanged test) on the final tree.
1300    let mut unchanged_ok: Vec<String> = Vec::new();
1301    for r in &mut fix.bug.reproductions {
1302        let class = run_class(c, r);
1303        r.final_ = if class == RedClass::Passed {
1304            Final::Passed
1305        } else {
1306            Final::Failed
1307        };
1308        if !r.reproduces() && r.final_ == Final::Passed {
1309            unchanged_ok.push(r.statement.clone());
1310        }
1311    }
1312    // 2. The pass-to-pass set from the diff.
1313    let changes = worktree::diff(c.wt_root, j.before, &fin).map_err(|e| e.0)?;
1314    let changed: Vec<String> = changes
1315        .iter()
1316        .filter_map(|ch| in_project(c, &ch.path))
1317        .filter(|p| !tools::paths::is_test_path(p))
1318        .collect();
1319    let test_files: Vec<(String, String)> = worktree::files(c.wt_root, &fin)
1320        .unwrap_or_default()
1321        .into_iter()
1322        .filter_map(|p| in_project(c, &p))
1323        .filter(|p| tools::paths::is_test_path(p) || changed.contains(p))
1324        .take(400)
1325        .filter_map(|p| {
1326            std::fs::read_to_string(c.project.join(&p))
1327                .ok()
1328                .map(|t| (p, t))
1329        })
1330        .collect();
1331    let baseline_tests: Vec<speccoder_core::verify::TestResult> = j
1332        .baseline
1333        .checks
1334        .iter()
1335        .flat_map(|ch| ch.tests.clone().unwrap_or_default())
1336        .collect();
1337    let final_tests: Vec<speccoder_core::verify::TestResult> = j
1338        .verification
1339        .map(|v| {
1340            v.checks
1341                .iter()
1342                .flat_map(|ch| ch.tests.clone().unwrap_or_default())
1343                .collect()
1344        })
1345        .unwrap_or_default();
1346    let crate_name = std::fs::read_to_string(c.project.join("Cargo.toml"))
1347        .ok()
1348        .and_then(|t| {
1349            t.lines().find_map(|l| {
1350                l.trim().strip_prefix("name").map(|r| {
1351                    r.trim_start_matches([' ', '='])
1352                        .trim()
1353                        .trim_matches('"')
1354                        .to_owned()
1355                })
1356            })
1357        });
1358    let unchanged_members: Vec<(String, String, String)> = fix
1359        .bug
1360        .reproductions
1361        .iter()
1362        .filter(|r| !r.reproduces())
1363        .map(|r| (r.statement.clone(), r.file.clone(), r.test.clone()))
1364        .collect();
1365    let mut p2p: PassToPass = crate::bug::unchanged::select(
1366        &changed,
1367        &test_files,
1368        &baseline_tests,
1369        crate_name.as_deref(),
1370        &unchanged_members,
1371    );
1372    crate::bug::unchanged::settle(&mut p2p, &final_tests, &|s| {
1373        unchanged_ok.iter().any(|u| u == s)
1374    });
1375    event(
1376        c.live,
1377        c.task,
1378        BugEvent::UnchangedSelected {
1379            state: if p2p.state == rules::PassToPassState::Selected {
1380                "selected"
1381            } else {
1382                "none_selected"
1383            }
1384            .to_owned(),
1385            count: p2p.members.len(),
1386        },
1387    );
1388
1389    // 3. The revert: the attempt's source changes undone, its tests kept.
1390    worktree::restore(c.wt_root, c.tmp, j.before).map_err(|e| e.0)?;
1391    let git = crate::git::Git::at(c.wt_root);
1392    for ch in &changes {
1393        if !in_project(c, &ch.path).is_some_and(|p| tools::paths::is_test_path(&p)) {
1394            continue;
1395        }
1396        let full = c.wt_root.join(&ch.path);
1397        match ch.kind {
1398            worktree::ChangeKind::Deleted => {
1399                let _ = std::fs::remove_file(&full);
1400            }
1401            _ => {
1402                let content = git
1403                    .run(&["show", &format!("{}:{}", fin.0, ch.path)])
1404                    .map_err(|e| e.0)?;
1405                if let Some(parent) = full.parent() {
1406                    let _ = std::fs::create_dir_all(parent);
1407                }
1408                std::fs::write(&full, content).map_err(|e| e.to_string())?;
1409            }
1410        }
1411    }
1412    let mut rejected_by = Vec::new();
1413    let mut any_unviable = false;
1414    for r in fix.bug.reproductions.iter_mut().filter(|r| r.reproduces()) {
1415        let class = run_class(c, r);
1416        r.revert = match class {
1417            RedClass::Assertion => {
1418                rejected_by.push(r.statement.clone());
1419                Revert::Rejected
1420            }
1421            RedClass::Passed => Revert::Survived,
1422            _ => {
1423                any_unviable = true;
1424                Revert::Unviable
1425            }
1426        };
1427    }
1428    worktree::restore(c.wt_root, c.tmp, &fin).map_err(|e| e.0)?;
1429    let revert = RevertResult {
1430        outcome: if !rejected_by.is_empty() {
1431            Revert::Rejected
1432        } else if any_unviable {
1433            Revert::Unviable
1434        } else {
1435            Revert::Survived
1436        },
1437        rejected_by,
1438    };
1439    stage(
1440        c.live,
1441        c.task,
1442        Stage::Unchanged,
1443        if p2p.held() && !revert.rejected_by.is_empty() {
1444            StageState::Passed
1445        } else {
1446            StageState::Failed
1447        },
1448        Some(format!(
1449            "{} pass-to-pass, revert {}",
1450            p2p.members.len(),
1451            revert_word(revert.outcome)
1452        )),
1453        Some(began),
1454    );
1455
1456    // 4. Special cases: one per statement, against the combined set.
1457    let mut special: Vec<SpecialCase> = Vec::new();
1458    let mut special_error: Option<String> = None;
1459    let mut weak = None;
1460    if !revert.rejected_by.is_empty()
1461        && fix
1462            .bug
1463            .reproductions
1464            .iter()
1465            .all(|r| r.final_ == Final::Passed)
1466    {
1467        let patch = worktree::patch(c.wt_root, c.start, &fin).unwrap_or_default();
1468        let changed_sources = crate::strength::changed_files(c.wt_root, &patch);
1469        let shown: Vec<(String, String, String)> = fix
1470            .bug
1471            .reproductions
1472            .iter()
1473            .filter(|r| r.reproduces())
1474            .filter_map(|r| {
1475                fix.files
1476                    .iter()
1477                    .find(|(p, _)| *p == r.file)
1478                    .map(|(p, content)| (r.statement.clone(), p.clone(), content.clone()))
1479            })
1480            .collect();
1481        if changed_sources.is_empty() {
1482            special_error =
1483                Some("the fix changes no source file a special case could replace".to_owned());
1484        } else {
1485            let context = manifest(c, &fix.bug, None);
1486            let input = substrate::near_miss::special_case_input(
1487                c.bundle,
1488                &changed_sources,
1489                &crate::mutation::source_patch(&patch),
1490                &shown,
1491                &context,
1492            );
1493            // A failed or empty answer is said, never read as "none needed".
1494            let variants = match c.rt.block_on(c.plane.near_miss(c.feature, c.task, input)) {
1495                Err(e) => {
1496                    special_error = Some(format!("the special-case author call failed: {}", e.0));
1497                    Vec::new()
1498                }
1499                Ok(o) => {
1500                    let answer = o
1501                        .output
1502                        .as_ref()
1503                        .map(|v| v.peek()["answer"].clone())
1504                        .unwrap_or_default();
1505                    if !matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
1506                        || answer.is_null()
1507                    {
1508                        special_error = Some(format!(
1509                            "the special-case author did not answer ({})",
1510                            o.status.as_str()
1511                        ));
1512                        Vec::new()
1513                    } else {
1514                        let v = special_variants(&answer, &changed_sources);
1515                        if v.is_empty() {
1516                            special_error = Some(
1517                                "the special-case author wrote no candidate that changes the fix's source files".to_owned(),
1518                            );
1519                        }
1520                        v
1521                    }
1522                }
1523            };
1524            let rulings = crate::strength::rulings(j.keep);
1525            for v in variants {
1526                let statement = v.scenario.clone();
1527                if special.iter().any(|s| s.statement == statement) {
1528                    continue;
1529                }
1530                let digest = v.digest();
1531                let ruled = rulings.iter().any(|r| r.digest == digest);
1532                let mut rejected = special_rejected(c, fix, &v, &fin)?;
1533                let mut regenerated = false;
1534                if !rejected && !ruled {
1535                    // One regeneration of the statement's variant, told what got through.
1536                    regenerated = true;
1537                    let visible: Vec<(String, String)> = shown
1538                        .iter()
1539                        .filter(|(s, _, _)| *s == statement)
1540                        .map(|(_, p, content)| (p.clone(), content.clone()))
1541                        .collect();
1542                    worktree::restore(c.wt_root, c.tmp, j.before).map_err(|e| e.0)?;
1543                    let told = vec![format!(
1544                        "a fix that special-cases this input passed the earlier variant: {}",
1545                        v.rationale
1546                    )];
1547                    let resealed = seal_variants(
1548                        c,
1549                        &mut fix.bug,
1550                        &visible,
1551                        std::slice::from_ref(&statement),
1552                        &told,
1553                    );
1554                    worktree::restore(c.wt_root, c.tmp, &fin).map_err(|e| e.0)?;
1555                    match resealed {
1556                        Ok(sealed) => {
1557                            fix.bug.held_out_sealed = Some(sealed.clone());
1558                            fix.record.held_out_sealed = Some(sealed);
1559                            crate::pre_frozen::store(c.state, &fix.record, &fix.files).map_err(
1560                                |e| format!("the re-sealed held-out set could not be kept: {e}"),
1561                            )?;
1562                            rejected = special_rejected(c, fix, &v, &fin)?;
1563                        }
1564                        // No new variant: the special case still stands, and why is said.
1565                        Err(why) => {
1566                            fix.bug.held_out_reason = Some(why);
1567                        }
1568                    }
1569                }
1570                if !rejected && !ruled {
1571                    weak = Some(speccoder_core::strength::Permitted {
1572                        id: format!("special-case-{statement}"),
1573                        scenario: statement.clone(),
1574                        rationale: v.rationale.clone(),
1575                        digest: digest.clone(),
1576                        outcome: speccoder_core::strength::PermittedOutcome::Rejected,
1577                        failing: None,
1578                        patch: None,
1579                    });
1580                }
1581                special.push(SpecialCase {
1582                    statement,
1583                    digest,
1584                    rejected,
1585                    regenerated,
1586                    ruled,
1587                });
1588            }
1589        }
1590    }
1591    // Held-out: every sealed variant — a regenerated one too — runs on the
1592    // final tree and must pass there.
1593    let held = match fix.bug.held_out_sealed.clone() {
1594        Some(sealed) => {
1595            let (m, contents) = held_out::open_sealed(c.state, &fix.bug.unit, &sealed)
1596                .map_err(|e| format!("the sealed held-out set could not be opened: {}", e.0))?;
1597            let files: Vec<(held_out::HeldOutFile, String)> = m
1598                .files
1599                .into_iter()
1600                .zip(contents.into_iter().map(|(_, x)| x))
1601                .collect();
1602            let runs =
1603                held_out::overlay_run(c.wt_root, c.project, c.tmp, &files, c.runner, c.policy)?;
1604            let mut failed: Vec<String> = Vec::new();
1605            let mut passed: Vec<String> = Vec::new();
1606            for ((f, _), r) in files.iter().zip(&runs) {
1607                let ok = r
1608                    .result
1609                    .as_ref()
1610                    .is_some_and(|(res, _)| res.status == CheckStatus::Passed);
1611                for st in &f.scenarios {
1612                    if ok {
1613                        passed.push(st.clone());
1614                    } else {
1615                        failed.push(st.clone());
1616                    }
1617                }
1618            }
1619            if runs.len() != files.len() {
1620                failed.push("held-out".to_owned());
1621            }
1622            for h in &mut fix.bug.held_out {
1623                h.final_ = if failed.contains(&h.statement) {
1624                    Final::Failed
1625                } else if passed.contains(&h.statement) {
1626                    Final::Passed
1627                } else {
1628                    Final::NotRun
1629                };
1630            }
1631            failed.sort();
1632            failed.dedup();
1633            if failed.is_empty() {
1634                HeldOut::Passed { files: files.len() }
1635            } else {
1636                HeldOut::Failed { scenarios: failed }
1637            }
1638        }
1639        None if c.held_out => HeldOut::Absent {
1640            reason: fix
1641                .bug
1642                .held_out_reason
1643                .clone()
1644                .unwrap_or_else(|| "none_kept".to_owned()),
1645        },
1646        None => HeldOut::Off,
1647    };
1648    // The tree and the judge inputs are as they were.
1649    let now = worktree::checkpoint(c.wt_root, c.tmp).map_err(|e| e.0)?;
1650    if now != fin {
1651        return Err("the bug's judging left changes in the worktree".to_owned());
1652    }
1653    let drift = judged_before.drift(
1654        &crate::integrity::JudgeSnapshot::take(c.wt_root, c.policy),
1655        c.policy,
1656        &std::collections::BTreeSet::new(),
1657    );
1658    if let Some(d) = drift.first() {
1659        return Err(format!("the bug's judging changed judge input {}", d.path));
1660    }
1661    event(
1662        c.live,
1663        c.task,
1664        BugEvent::Discrimination {
1665            revert: revert_word(revert.outcome),
1666            special_case: special
1667                .iter()
1668                .map(|s| crate::progress::ShownSpecialCase {
1669                    statement: s.statement.clone(),
1670                    rejected: s.rejected || s.ruled,
1671                })
1672                .collect(),
1673        },
1674    );
1675    let verdict = {
1676        let d = rules::Discrimination {
1677            reproductions: &fix.bug.reproductions,
1678            revert: &revert,
1679            special_case: &special,
1680            special_case_error: special_error.as_deref(),
1681        };
1682        rules::bug_verdict(j.base.clone(), &d, &p2p, &held)
1683    };
1684    fix.bug.pass_to_pass = Some(p2p);
1685    fix.bug.revert = Some(revert);
1686    fix.bug.special_case = special;
1687    state::save(c.state, &fix.bug).map_err(|e| format!("the bug could not be kept: {e}"))?;
1688    Ok(Judged { verdict, weak })
1689}
1690
1691fn revert_word(r: Revert) -> String {
1692    match r {
1693        Revert::Rejected => "rejected",
1694        Revert::Survived => "survived",
1695        Revert::Unviable => "unviable",
1696        Revert::NotRun => "not_run",
1697    }
1698    .to_owned()
1699}
1700
1701/// The author's special cases, checked like any variant: only changed
1702/// source files, each a real change.
1703fn special_variants(answer: &Value, changed: &[(String, String)]) -> Vec<crate::strength::Variant> {
1704    let mut out = Vec::new();
1705    for v in answer["variants"].as_array().into_iter().flatten() {
1706        if v["kind"] != "special_case" {
1707            continue;
1708        }
1709        let mut files = Vec::new();
1710        let mut ok = true;
1711        for f in v["files"].as_array().into_iter().flatten() {
1712            let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
1713                ok = false;
1714                break;
1715            };
1716            match tools::paths::normalise(path).ok().and_then(|p| {
1717                changed
1718                    .iter()
1719                    .find(|(c, _)| *c == p)
1720                    .map(|(c, now)| (c.clone(), now.clone()))
1721            }) {
1722                Some((p, now)) if now != content => files.push((p, content.to_owned())),
1723                Some(_) => {}
1724                None => {
1725                    ok = false;
1726                    break;
1727                }
1728            }
1729        }
1730        if ok && !files.is_empty() {
1731            out.push(crate::strength::Variant {
1732                kind: "special_case".to_owned(),
1733                scenario: v["scenario"].as_str().unwrap_or_default().to_owned(),
1734                rationale: v["rationale"].as_str().unwrap_or_default().to_owned(),
1735                files,
1736            });
1737        }
1738    }
1739    out
1740}
1741
1742/// Whether the combined set (the statement's reproduction and its held-out
1743/// variants) rejects the special case by an assertion.
1744fn special_rejected(
1745    c: &Ctx<'_>,
1746    fix: &Fix,
1747    v: &crate::strength::Variant,
1748    fin: &worktree::Checkpoint,
1749) -> Result<bool, String> {
1750    for (p, content) in &v.files {
1751        std::fs::write(c.wt_root.join(p), content).map_err(|e| format!("{p}: {e}"))?;
1752    }
1753    let mut rejected = false;
1754    for r in fix
1755        .bug
1756        .reproductions
1757        .iter()
1758        .filter(|r| r.statement == v.scenario)
1759    {
1760        if run_class(c, r) == RedClass::Assertion {
1761            rejected = true;
1762        }
1763    }
1764    if !rejected
1765        && let Some(sealed) = &fix.bug.held_out_sealed
1766        && let Ok((m, contents)) = held_out::open_sealed(c.state, &fix.bug.unit, sealed)
1767    {
1768        let files: Vec<(held_out::HeldOutFile, String)> = m
1769            .files
1770            .into_iter()
1771            .zip(contents.into_iter().map(|(_, x)| x))
1772            .filter(|(f, _)| f.scenarios.contains(&v.scenario))
1773            .collect();
1774        let runs = held_out::overlay_run(c.wt_root, c.project, c.tmp, &files, c.runner, c.policy)?;
1775        rejected = runs
1776            .iter()
1777            .filter_map(|r| r.result.as_ref())
1778            .any(|(res, s)| classify_red(res, s.as_ref()) == RedClass::Assertion);
1779    }
1780    worktree::restore(c.wt_root, c.tmp, fin).map_err(|e| e.0)?;
1781    Ok(rejected)
1782}
1783
1784/// The trailers a bug task's tick commit carries; `rulings`: the person's
1785/// rulings on special cases (`task rule --tests-right`), named when one
1786/// let a special case through.
1787pub(crate) fn trailers(
1788    fix: &Fix,
1789    verdict_word: &str,
1790    rulings: &[speccoder_core::strength::Ruling],
1791) -> String {
1792    let outcome = if matches!(verdict_word, "verified" | "attested") {
1793        "fixed"
1794    } else {
1795        "failed"
1796    };
1797    let mut out = format!(
1798        "Bug: {outcome} ({}) triad={}\n",
1799        fix.bug.home.kind.as_str(),
1800        fix.bug.triad.digest
1801    );
1802    out.push_str(
1803        &fix.record
1804            .trailer(&crate::pre_frozen::manifest_digest(&fix.record)),
1805    );
1806    for s in fix
1807        .bug
1808        .special_case
1809        .iter()
1810        .filter(|s| s.ruled && !s.rejected)
1811    {
1812        if let Some(r) = rulings.iter().find(|r| r.digest == s.digest) {
1813            out.push_str(&format!(
1814                "Ruling: tests-right {} {} by {} — {}\n",
1815                s.statement, r.digest, r.by, r.reason
1816            ));
1817        }
1818    }
1819    if let Some(i) = fix.bug.issue.as_ref().filter(|_| outcome == "fixed") {
1820        // A trailer git parses (`Fixes #N` without the colon would make
1821        // git read none of the block); GitHub closes on it either way.
1822        out.push_str(&format!("Fixes: #{}\n", i.number));
1823    }
1824    out
1825}
1826
1827/// The predicate's `bug` and `acceptance` fields.
1828pub(crate) fn predicate_fields(
1829    bug: &BugReport,
1830    record: &speccoder_core::acceptance::PreFrozenAcceptance,
1831) -> (Value, Value) {
1832    let b = json!({
1833        "home": bug.home.kind.as_str(),
1834        "triad_sha256": bug.triad.digest,
1835        "outcome": bug.outcome.as_str(),
1836        "reproductions": bug.reproductions.iter().map(|r| json!({
1837            "statement": r.statement, "baseline": r.baseline, "final": r.final_, "revert": r.revert,
1838        })).collect::<Vec<_>>(),
1839        "special_case": bug.special_case.iter().map(|s| json!({
1840            "statement": s.statement, "rejected": s.rejected, "regenerated": s.regenerated,
1841        })).collect::<Vec<_>>(),
1842        "pass_to_pass": bug.pass_to_pass.as_ref().map(|p| json!({ "state": p.state, "count": p.members.len() })),
1843    });
1844    let a = json!({
1845        "source": record.producer.source(),
1846        "pre_frozen": {
1847            "manifest_sha256": crate::pre_frozen::manifest_digest(record),
1848            "approval_sha256": record.approval_digest,
1849            "wording_sha256": record.wording_digest,
1850            "by": record.decided.by,
1851            "via": record.decided.via,
1852        },
1853    });
1854    (b, a)
1855}
1856
1857/// After the run: the bug's outcome from the task's.
1858pub(crate) fn finish(
1859    state_dir: &Path,
1860    fix: &mut Fix,
1861    report: &speccoder_core::RunReport,
1862    passed: bool,
1863) {
1864    // What the attempt recorded of the candidate meanwhile.
1865    if let Some(saved) = state::load(state_dir, &fix.bug.unit, &fix.bug.task) {
1866        fix.bug.candidate = saved.candidate;
1867    }
1868    fix.bug.verdict.clone_from(&report.verdict);
1869    fix.bug.run_id = (!report.run_id.is_empty()).then(|| report.run_id.clone());
1870    fix.bug.commit.clone_from(&report.commit);
1871    fix.bug.outcome = if passed {
1872        Outcome::Fixed
1873    } else {
1874        Outcome::Failed
1875    };
1876    fix.bug.stage = if passed {
1877        BugStage::Done
1878    } else {
1879        BugStage::Fix
1880    };
1881    fix.bug.code = (!passed
1882        && fix
1883            .bug
1884            .revert
1885            .as_ref()
1886            .is_some_and(|r| r.rejected_by.is_empty()))
1887    .then(|| "BUG-NOT-DISCRIMINATING".to_owned());
1888    let _ = state::save(state_dir, &fix.bug);
1889}
1890
1891/// Someone else's fix as the attempt: refused before it is applied when it
1892/// touches a frozen reproduction, a held-out path or a judge input, or
1893/// when it does not apply. `Ok`: `(run id, succeeded, output)` as an
1894/// attempt reports them.
1895pub(crate) fn apply_candidate(
1896    c: &Ctx<'_>,
1897    diff: &str,
1898    candidate: &rules::Candidate,
1899    frozen: &[String],
1900    held_out_paths: &[String],
1901) -> Result<(String, bool, Value), Box<CommandResult>> {
1902    let refused = |code: &str, paths: Vec<String>, status: CommandStatus, message: String| {
1903        let _ = worktree::restore(c.wt_root, c.tmp, c.start);
1904        if let Some(mut bug) = state::load(c.state, c.feature, c.task) {
1905            if let Some(cand) = bug.candidate.as_mut() {
1906                cand.refused = Some(rules::CandidateRefusal {
1907                    code: code.to_owned(),
1908                    paths: paths.clone(),
1909                });
1910            }
1911            bug.outcome = Outcome::Failed;
1912            bug.code = Some(code.to_owned());
1913            let _ = state::save(c.state, &bug);
1914        }
1915        let mut report = bare_report(c.feature, c.task, c.wt_path, "candidate_refused");
1916        report.reason = Some(message.clone());
1917        report.bug = state::load(c.state, c.feature, c.task).map(Box::new);
1918        Box::new(
1919            CommandResult::new("run", status, &message)
1920                .with_code(code)
1921                .with_data(CommandData::Run(Box::new(report))),
1922        )
1923    };
1924    // The paths as git reads the diff: renames, mode changes and quoted
1925    // paths included; a binary hunk or a symbolic link is not applied.
1926    let touched = match crate::bug::candidate::touched(c.wt_root, diff) {
1927        Ok(t) => t,
1928        Err(r) => {
1929            let message = format!("the candidate is not applied: {}", r.paths.join("; "));
1930            return Err(refused(
1931                "BUG-CANDIDATE-APPLY",
1932                r.paths,
1933                CommandStatus::GitPrecondition,
1934                message,
1935            ));
1936        }
1937    };
1938    let judged = crate::bug::candidate::judge_paths(&touched, c.policy, frozen, held_out_paths);
1939    if !judged.is_empty() {
1940        return Err(refused(
1941            "BUG-CANDIDATE-JUDGE",
1942            judged.clone(),
1943            CommandStatus::UsageError,
1944            format!(
1945                "the candidate touches what judges it, so it is not applied: {}",
1946                judged.join(", ")
1947            ),
1948        ));
1949    }
1950    if let Err(r) = crate::bug::candidate::apply(c.wt_root, c.tmp, diff) {
1951        let message = format!(
1952            "the candidate does not apply on the branch: {}",
1953            if r.paths.is_empty() {
1954                "git refused it".to_owned()
1955            } else {
1956                r.paths.join("; ")
1957            }
1958        );
1959        return Err(refused(
1960            "BUG-CANDIDATE-APPLY",
1961            r.paths,
1962            CommandStatus::GitPrecondition,
1963            message,
1964        ));
1965    }
1966    if let Some(mut bug) = state::load(c.state, c.feature, c.task) {
1967        if let Some(cand) = bug.candidate.as_mut() {
1968            cand.applied = true;
1969        }
1970        let _ = state::save(c.state, &bug);
1971    }
1972    let short: String = candidate
1973        .digest
1974        .trim_start_matches("sha256:")
1975        .chars()
1976        .take(12)
1977        .collect();
1978    let from = match (&candidate.path, candidate.number) {
1979        (_, Some(n)) => format!("pull request #{n}"),
1980        (Some(p), None) => p.clone(),
1981        (None, None) => "a patch".to_owned(),
1982    };
1983    Ok((
1984        format!("candidate_{short}"),
1985        true,
1986        json!({
1987            "status": "completed",
1988            "report": { "summary": format!("applied {from} as the attempt"), "status": "completed" },
1989            "tool_calls": [],
1990        }),
1991    ))
1992}
1993
1994/// The run's result for a bug task: the bug's outcome from the task's, its
1995/// codes and the event.
1996pub(crate) fn finished(
1997    c: &Ctx<'_>,
1998    fix: &mut Fix,
1999    mut report: speccoder_core::RunReport,
2000    attempt_status: &str,
2001    passed: bool,
2002    status: CommandStatus,
2003    message: String,
2004) -> CommandResult {
2005    let done = passed && (report.ticked || report.commit.is_some());
2006    finish(c.state, fix, &report, done);
2007    let mut status = status;
2008    let mut code = None;
2009    if attempt_status == "repro_weak" {
2010        status = CommandStatus::NeedsHuman;
2011        code = Some("BUG-REPRO-WEAK");
2012        fix.bug.code = Some("BUG-REPRO-WEAK".to_owned());
2013        let _ = state::save(c.state, &fix.bug);
2014    } else if !done
2015        && fix
2016            .bug
2017            .revert
2018            .as_ref()
2019            .is_some_and(|r| r.rejected_by.is_empty())
2020    {
2021        code = Some("BUG-NOT-DISCRIMINATING");
2022    }
2023    let next = handoff::next(&fix.bug, fix.bug.outcome);
2024    event(
2025        c.live,
2026        c.task,
2027        BugEvent::Outcome {
2028            outcome: fix.bug.outcome.as_str().to_owned(),
2029            verdict: report.verdict.as_ref().map(super::outcome::verdict_word),
2030            code: code.map(str::to_owned),
2031            next: next.iter().map(|n| n.command.clone()).collect(),
2032        },
2033    );
2034    report.bug = Some(Box::new(fix.bug.clone()));
2035    c.live.event(&super::finished_event(
2036        c.task,
2037        &report,
2038        attempt_status,
2039        false,
2040        message.clone(),
2041    ));
2042    let mut r =
2043        CommandResult::new("run", status, &message).with_data(CommandData::Run(Box::new(report)));
2044    if let Some(code) = code {
2045        r = r.with_code(code);
2046    }
2047    r
2048}
2049
2050/// A bug in the format's own bug structure, as a run takes it: the unit
2051/// no task list names, its directory, and what the attempt is given.
2052#[derive(Debug, Clone, PartialEq, Eq)]
2053pub struct NativeBug {
2054    /// The unit (`bugs/<slug>`).
2055    pub unit: String,
2056    /// Its directory, project-relative.
2057    pub dir: String,
2058    /// The task (`bug:<slug>`).
2059    pub task: String,
2060    /// The title.
2061    pub title: String,
2062    /// What the attempt reads as the requirement: the person's triad.
2063    pub story: String,
2064}
2065
2066impl NativeBug {
2067    /// The bug of `bug`.
2068    #[must_use]
2069    pub fn of(bug: &BugReport) -> Self {
2070        Self {
2071            unit: bug.unit.clone(),
2072            dir: bug.home.location.clone(),
2073            task: bug.task.clone(),
2074            title: bug.title.clone(),
2075            story: format!(
2076                "A bug, in the reporter's words.\n\n{}",
2077                bug.triad.labelled()
2078            ),
2079        }
2080    }
2081
2082    /// The context bundle an attempt at it is given.
2083    #[must_use]
2084    pub fn bundle(&self, project: &Path) -> crate::context::Bundle {
2085        let [rules, ..] =
2086            crate::format::at(project).convergence_texts(project, &project.join(&self.dir));
2087        crate::context::Bundle {
2088            feature: self.unit.clone(),
2089            constitution: crate::context::cap(&rules),
2090            plan: String::new(),
2091            story: crate::context::cap(&self.story),
2092            task: crate::context::TaskContext {
2093                id: self.task.clone(),
2094                title: format!("Fix: {}", self.title),
2095                phase: String::new(),
2096                subsection: String::new(),
2097                story: String::new(),
2098                parallel: false,
2099            },
2100            dependencies: Vec::new(),
2101        }
2102    }
2103}
2104
2105#[cfg(test)]
2106mod tests {
2107    use super::*;
2108
2109    #[test]
2110    fn a_reproduction_is_a_test_file_run_by_speccoders_own_command() {
2111        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2112        let policy = Policy::new(
2113            &[],
2114            &[vec![
2115                "./run-one".to_owned(),
2116                "tests/repro.sh".to_owned(),
2117                "empty".to_owned(),
2118            ]],
2119        );
2120        let template = [
2121            "./run-one".to_owned(),
2122            "{file}".to_owned(),
2123            "{name}".to_owned(),
2124        ];
2125        let want = ["E1".to_owned()];
2126        let answer = |path: &str, test: &str| json!({ "files": [{ "path": path, "content": "x", "statement": "E1", "test": test, "argv": ["sh", "-c", "exit 1"] }] });
2127        // The author's command is never run: speccoder's runs only the test.
2128        let ok = validate(
2129            &answer("tests/repro.sh", "empty"),
2130            &want,
2131            dir.path(),
2132            &policy,
2133            Some(&template),
2134        );
2135        let Ok(files) = ok else {
2136            panic!("{ok:?}", ok = ok.err());
2137        };
2138        assert_eq!(files[0].argv, ["./run-one", "tests/repro.sh", "empty"]);
2139        // A file that configures how every test runs is not a reproduction.
2140        for path in [
2141            "tests/conftest.py",
2142            "tests/__init__.py",
2143            "tests/vitest.setup.ts",
2144        ] {
2145            let e = validate(
2146                &answer(path, "t"),
2147                &want,
2148                dir.path(),
2149                &policy,
2150                Some(&template),
2151            )
2152            .err();
2153            assert!(
2154                e.as_deref()
2155                    .is_some_and(|e| e.contains("configures how tests run")),
2156                "{path}: {e:?}"
2157            );
2158        }
2159        // A test without a name, or with no rule to run it alone, is not one.
2160        assert!(
2161            validate(
2162                &answer("tests/repro.sh", " "),
2163                &want,
2164                dir.path(),
2165                &policy,
2166                Some(&template)
2167            )
2168            .is_err()
2169        );
2170        assert!(
2171            validate(
2172                &answer("tests/repro.unknown", "t"),
2173                &want,
2174                dir.path(),
2175                &policy,
2176                None
2177            )
2178            .err()
2179            .is_some_and(|e| e.contains("[bug] test"))
2180        );
2181    }
2182}