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