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