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