1use 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
33pub(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 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 pub(crate) held_out: bool,
53 pub(crate) max_tries: u32,
55 pub(crate) bug_test: Option<&'a [String]>,
57 pub(crate) start: &'a worktree::Checkpoint,
59}
60
61pub(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
68pub(crate) enum Phase {
70 Reproduce(Box<BugReport>),
72 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
83pub(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(¤t, &read.expected, &read.unchanged, sha256_hex)
97 .map_err(|e| crate::format::BugRefusal::new(e.code(), e.message()))
98}
99
100pub(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 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 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
145pub(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
157fn 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
228struct Authored {
230 path: String,
231 content: String,
232 statement: String,
233 test: String,
234 argv: Vec<String>,
235}
236
237fn 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
270fn 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 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
335fn 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
355fn 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
415fn 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
453fn 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#[allow(clippy::too_many_lines)] pub(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 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 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 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 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 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 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 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
990pub(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
1061fn 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 let mut red: Vec<(held_out::HeldOutFile, String)> = Vec::new();
1150 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 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
1221pub(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
1246pub(crate) struct Judged {
1248 pub(crate) verdict: Verdict,
1250 pub(crate) weak: Option<speccoder_core::strength::Permitted>,
1253}
1254
1255pub(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 pub(crate) keep: &'a Path,
1263}
1264
1265fn 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
1276fn 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#[allow(clippy::too_many_lines)] pub(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 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 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 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 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 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 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 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 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 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
1710fn 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
1751fn 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
1793pub(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 out.push_str(&format!("Fixes: #{}\n", i.number));
1832 }
1833 out
1834}
1835
1836pub(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
1866pub(crate) fn finish(
1868 state_dir: &Path,
1869 fix: &mut Fix,
1870 report: &speccoder_core::RunReport,
1871 passed: bool,
1872) {
1873 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
1900pub(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 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
2003pub(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#[derive(Debug, Clone, PartialEq, Eq)]
2062pub struct NativeBug {
2063 pub unit: String,
2065 pub dir: String,
2067 pub task: String,
2069 pub title: String,
2071 pub story: String,
2073}
2074
2075impl NativeBug {
2076 #[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 #[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 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 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 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}