1use std::collections::BTreeMap;
8use std::path::Path;
9
10use speccoder_core::spec::ScenarioRef;
11use speccoder_core::story::{Progress, ScenarioState};
12use speccoder_core::verify::{CheckStatus, Verdict};
13
14use crate::oracle::FrozenTest;
15use crate::sandbox;
16
17pub(crate) const ORACLE: &str = "Oracle";
19
20#[derive(Debug, Clone)]
22pub(crate) struct StoryOracle {
23 pub(crate) story: String,
25 pub(crate) scenarios: Vec<ScenarioRef>,
27 pub(crate) tests: Vec<FrozenTest>,
29 pub(crate) contracts: BTreeMap<String, String>,
31 pub(crate) commit: String,
33 pub(crate) decided_by: String,
35 pub(crate) held_out: Option<speccoder_core::acceptance::SealedRef>,
37 pub(crate) held_out_summary: Option<speccoder_core::oracle::HeldOutSummary>,
39 pub(crate) reviewed: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)>,
42 pub(crate) at_oracle: BTreeMap<String, ScenarioState>,
44 pub(crate) usage: Option<serde_json::Value>,
46 pub(crate) untestable: BTreeMap<String, String>,
49}
50
51impl StoryOracle {
52 pub(crate) fn green_from_the_start(&self) -> bool {
55 !self.at_oracle.is_empty()
56 && self
57 .scenarios
58 .iter()
59 .filter(|s| !self.untestable.contains_key(&s.id))
60 .all(|s| self.at_oracle.get(&s.id) == Some(&ScenarioState::Green))
61 }
62}
63
64impl StoryOracle {
65 pub(crate) fn order(&self) -> Vec<String> {
67 self.scenarios.iter().map(|s| s.id.clone()).collect()
68 }
69}
70
71pub(crate) fn commit_of(wt_root: &Path, story: &str) -> Result<Option<String>, String> {
75 let out = crate::git::Git::at(wt_root)
76 .line(&[
77 "log",
78 "--format=%H",
79 "-n1",
80 &format!("--grep=^{ORACLE}: {story}$"),
81 "HEAD",
82 ])
83 .map_err(|e| e.0)?;
84 Ok(Some(out).filter(|s| !s.is_empty()))
85}
86
87pub(crate) fn open_oracle(
92 wt_root: &Path,
93 snapshot: &speccoder_core::snapshot::ProjectSnapshot,
94 format: &dyn crate::format::Adapter,
95) -> bool {
96 let whole_unit = format.kind() == speccoder_core::spec::FormatKind::Openspec;
97 let Some(f) = snapshot.feature.as_ref() else {
98 return false;
99 };
100 let tasks = f
101 .tasks
102 .as_ref()
103 .map(|t| t.tasks.as_slice())
104 .unwrap_or_default();
105 let mut open: Vec<String> = if whole_unit {
106 if tasks.iter().any(|t| !t.done) {
107 vec![f.feature.name.clone()]
108 } else {
109 Vec::new()
110 }
111 } else {
112 let reqs = format.oracle_requirements(snapshot);
114 tasks
115 .iter()
116 .filter(|t| !t.done)
117 .filter_map(|t| {
118 t.story.clone().or_else(|| {
119 super::acceptance::linked_story(
120 t,
121 &format.scenarios(snapshot, &t.id),
122 &f.feature.dir,
123 &format.rules().judged,
124 &reqs,
125 )
126 .map(|(r, _)| r.key)
127 })
128 })
129 .collect()
130 };
131 open.sort();
132 open.dedup();
133 open.iter()
134 .any(|story| matches!(commit_of(wt_root, story), Ok(Some(_))))
135}
136
137pub(crate) fn oracle_pending(
142 wt_root: &Path,
143 plane_dir: &Path,
144 format: &dyn crate::format::Adapter,
145 snapshot: &speccoder_core::snapshot::ProjectSnapshot,
146 task: &str,
147) -> bool {
148 let Some(f) = snapshot.feature.as_ref() else {
149 return false;
150 };
151 let Some(t) = f
152 .tasks
153 .as_ref()
154 .and_then(|t| t.tasks.iter().find(|t| t.id == task))
155 else {
156 return false;
157 };
158 let key = match t.story.clone() {
159 Some(l) => l,
160 None if format.kind() == speccoder_core::spec::FormatKind::Openspec => {
161 f.feature.name.clone()
162 }
163 None => match super::acceptance::linked_story(
165 t,
166 &format.scenarios(snapshot, task),
167 &f.feature.dir,
168 &format.rules().judged,
169 &format.oracle_requirements(snapshot),
170 ) {
171 Some((r, _)) => r.key,
172 None => return false,
173 },
174 };
175 if format.scenarios(snapshot, task).is_empty() {
176 return false;
177 }
178 !matches!(commit_of(wt_root, &key), Ok(Some(_)))
179 || !crate::acceptance_at::rulings_for(plane_dir, &f.feature.name, &key)
180 .unwrap_or_default()
181 .is_empty()
182}
183
184pub(crate) struct Ensure<'a> {
186 pub(crate) key: &'a str,
188 pub(crate) scenarios: &'a [ScenarioRef],
190 pub(crate) feature: &'a str,
191 pub(crate) project: &'a Path,
193 pub(crate) wt_root: &'a Path,
194 pub(crate) state: &'a Path,
196 pub(crate) plane_dir: &'a Path,
197 pub(crate) runner: &'a sandbox::Runner,
198 pub(crate) rt: &'a tokio::runtime::Runtime,
200 pub(crate) plane: &'a crate::substrate::Plane,
201 pub(crate) options: &'a crate::RunOptions,
202 pub(crate) format: &'a dyn crate::format::Adapter,
203 pub(crate) snapshot: &'a speccoder_core::snapshot::ProjectSnapshot,
204 pub(crate) fresh: bool,
206 pub(crate) resuming: bool,
208}
209
210#[must_use]
214pub fn check_story(
215 path: &Path,
216 feature: Option<&str>,
217 story: &str,
218 options: &crate::RunOptions,
219) -> speccoder_core::CommandResult {
220 super::feature::with_driver("story", path, feature, |state, provider, _| {
221 check_with(path, feature, story, state, provider, options)
222 })
223}
224
225#[must_use]
227pub fn check_with(
228 path: &Path,
229 feature: Option<&str>,
230 story: &str,
231 state: &Path,
232 provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
233 options: &crate::RunOptions,
234) -> speccoder_core::CommandResult {
235 use speccoder_core::{CommandResult, CommandStatus};
236 let c = match checked(path, feature, story, state, provider, options) {
237 Ok(c) => c,
238 Err(r) => return *r,
239 };
240 let (now, scenarios, finished, wrote) = (&c.states, &c.scenarios, c.finished, c.wrote);
241 let red: Vec<&str> = scenarios
242 .iter()
243 .filter(|sc| now.get(&sc.id) == Some(&ScenarioState::Red))
244 .map(|sc| sc.id.as_str())
245 .collect();
246 let green = scenarios
247 .iter()
248 .filter(|sc| now.get(&sc.id) == Some(&ScenarioState::Green))
249 .count();
250 let how = if wrote {
251 "its oracle written and run"
252 } else {
253 "its oracle run again"
254 };
255 let message = if red.is_empty() {
256 format!(
257 "{story}: {how} — {green} of {} scenario(s) hold",
258 scenarios.len()
259 )
260 } else {
261 format!(
262 "{story}: {how} — {green} of {} scenario(s) hold; {} {}",
263 scenarios.len(),
264 red.join(", "),
265 if finished {
266 "not held by the code"
267 } else {
268 "still to turn green"
269 }
270 )
271 };
272 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
273 let step = |command: String, why: &str| speccoder_core::next::NextStep {
274 command,
275 why: why.to_owned(),
276 };
277 let next = if red.is_empty() || !finished {
278 vec![step(
279 format!("speccoder report{f}"),
280 "each story on its oracle",
281 )]
282 } else {
283 let mut next = vec![step(
285 format!("speccoder converge{f}"),
286 "append the tasks that make the code hold them",
287 )];
288 if let Some(last) = &c.last {
289 next.push(step(
290 format!(
291 "speccoder task rule {last}{f} --tests-wrong --scenario {} --reason \"…\"",
292 red[0]
293 ),
294 "when a test asserts what the spec does not say",
295 ));
296 }
297 next
298 };
299 CommandResult::new("story", CommandStatus::Ok, &message).with_next(next)
300}
301
302pub(crate) struct Checked {
305 pub(crate) states: BTreeMap<String, ScenarioState>,
307 pub(crate) untestable: Vec<String>,
309 pub(crate) scenarios: Vec<speccoder_core::spec::ScenarioRef>,
311 pub(crate) finished: bool,
313 pub(crate) last: Option<String>,
315 pub(crate) wrote: bool,
317 pub(crate) usage: Option<serde_json::Value>,
319}
320
321pub(crate) fn checked(
323 path: &Path,
324 feature: Option<&str>,
325 story: &str,
326 state: &Path,
327 provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
328 options: &crate::RunOptions,
329) -> Result<Checked, Box<speccoder_core::CommandResult>> {
330 use speccoder_core::{CommandData, CommandResult, CommandStatus};
331 let refuse = |status, m: String| CommandResult::new("story", status, &m);
332 let s = super::setup(path, feature, state, "story")?;
333 let Some(name) = s.snapshot.feature.as_ref().map(|f| f.feature.name.clone()) else {
334 return Err(Box::new(refuse(
335 CommandStatus::SelectionNeeded,
336 "no feature selected; pass --feature".to_owned(),
337 )));
338 };
339 let wt = super::workspace(path, Some(&name), state, options);
341 let Some(CommandData::Worktree(w)) = wt.data() else {
342 return Err(Box::new(CommandResult::carried("story", &wt)));
343 };
344 if wt.status() != CommandStatus::Ok {
345 return Err(Box::new(CommandResult::carried("story", &wt)));
346 }
347 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
348 return Err(Box::new(refuse(
349 CommandStatus::Error,
350 "worktree without a path".to_owned(),
351 )));
352 };
353 let (project, wt_root) = (
354 std::path::PathBuf::from(project_dir),
355 std::path::PathBuf::from(wt_path),
356 );
357 let format = crate::format::at(&project);
358 let Some(snap) = format.inspect(&project, Some(&name)) else {
359 return Err(Box::new(refuse(
360 CommandStatus::NotAProject,
361 "the feature's branch holds no spec".to_owned(),
362 )));
363 };
364 let tasks: Vec<&speccoder_core::snapshot::Task> = snap
365 .feature
366 .as_ref()
367 .and_then(|f| f.tasks.as_ref())
368 .map(|ts| ts.tasks.iter().collect())
369 .unwrap_or_default();
370 let whole = format.kind() == speccoder_core::spec::FormatKind::Openspec && story == name;
371 let members: Vec<&&speccoder_core::snapshot::Task> = tasks
372 .iter()
373 .filter(|t| whole || t.story.as_deref() == Some(story))
374 .collect();
375 let Some(first) = members.first() else {
376 let mut known: Vec<&str> = tasks.iter().filter_map(|t| t.story.as_deref()).collect();
377 known.sort_unstable();
378 known.dedup();
379 return Err(Box::new(refuse(
380 CommandStatus::NotFound,
381 format!("{story} is no story of {name} ({})", known.join(", ")),
382 )));
383 };
384 let scenarios = format.scenarios(&snap, &first.id);
385 if scenarios.is_empty() {
386 return Err(Box::new(refuse(
387 CommandStatus::NotFound,
388 format!("{story} has no acceptance scenario to check"),
389 )));
390 }
391 let (runner, _) = match sandbox::runner_at(&wt_root) {
392 Ok(r) => r,
393 Err(e) => return Err(Box::new(refuse(CommandStatus::ConfigError, e))),
394 };
395 let rt = match super::tokio_runtime() {
396 Ok(rt) => rt,
397 Err(e) => return Err(Box::new(refuse(CommandStatus::RunFailed, e))),
398 };
399 let plane = match rt.block_on(async {
400 crate::substrate::Plane::open_with(
401 &s.plane_dir,
402 &s.model,
403 std::sync::Arc::clone(provider),
404 None,
405 crate::substrate::Hooks {
406 format: Some(format),
407 progress: options.progress.clone(),
408 ..crate::substrate::Hooks::default()
409 },
410 )
411 }) {
412 Ok(p) => p,
413 Err(e) => return Err(Box::new(refuse(CommandStatus::RunFailed, e.0))),
414 };
415 let e = Ensure {
416 key: story,
417 scenarios: &scenarios,
418 feature: &name,
419 project: &project,
420 wt_root: &wt_root,
421 state,
422 plane_dir: &s.plane_dir,
423 runner: &runner,
424 rt: &rt,
425 plane: &plane,
426 options,
427 format,
428 snapshot: &snap,
429 fresh: false,
430 resuming: false,
431 };
432 let (oracle, wrote, _) = ensure(&e)?;
433 let states = states(&project, &oracle, &runner);
434 Ok(Checked {
435 states,
436 untestable: oracle.untestable.keys().cloned().collect(),
437 scenarios,
438 finished: members.iter().all(|t| t.done),
439 last: members.last().map(|t| t.id.clone()),
440 wrote,
441 usage: oracle.usage.clone(),
442 })
443}
444
445fn stop(code: &str, message: String) -> Box<speccoder_core::CommandResult> {
446 Box::new(
447 speccoder_core::CommandResult::new(
448 "run",
449 speccoder_core::CommandStatus::NeedsHuman,
450 &message,
451 )
452 .with_code(code),
453 )
454}
455
456fn unwritten(story: &str, message: String) -> Box<speccoder_core::CommandResult> {
458 let mut res = stop("ORACLE-UNWRITTEN", message);
459 *res = res.with_fix(vec![format!("speccoder review tests {story} --write")]);
460 res
461}
462
463pub(crate) fn ensure(
469 e: &Ensure<'_>,
470) -> Result<(StoryOracle, bool, Vec<String>), Box<speccoder_core::CommandResult>> {
471 let mut notices = Vec::new();
472 if let Err(t) = crate::pre_frozen::load(e.plane_dir, e.feature, e.key) {
475 return Err(stop(
476 "ORACLE-TAMPERED",
477 format!(
478 "{}'s frozen oracle changed since it was written ({}): the task stops for a person, nothing is ticked",
479 e.key, t.0
480 ),
481 ));
482 }
483 let sealed = || {
484 crate::pre_frozen::load(e.plane_dir, e.feature, e.key)
485 .ok()
486 .flatten()
487 .and_then(|(r, _)| r.held_out_sealed)
488 };
489 let oracle = |tests: Vec<FrozenTest>, contracts, commit: String, by: String| StoryOracle {
490 story: e.key.to_owned(),
491 scenarios: e.scenarios.to_vec(),
492 tests,
493 contracts,
494 commit,
495 decided_by: by,
496 held_out: sealed(),
497 held_out_summary: crate::oracle::load(e.plane_dir, e.feature, e.key)
498 .ok()
499 .flatten()
500 .map(|(d, _)| d.held_out),
501 reviewed: None,
502 at_oracle: BTreeMap::new(),
503 usage: None,
504 untestable: BTreeMap::new(),
505 };
506 if let Some(commit) = commit_of(e.wt_root, e.key).map_err(|m| unwritten(e.key, m))? {
507 if tests_of(e.wt_root, &commit) == Some(0) {
509 return Ok((
510 oracle(
511 Vec::new(),
512 BTreeMap::new(),
513 commit,
514 "speccoder run".to_owned(),
515 ),
516 false,
517 notices,
518 ));
519 }
520 let Some(frozen) = crate::oracle::load_frozen(e.plane_dir, e.feature, e.key) else {
521 return Err(unwritten(
522 e.key,
523 format!(
524 "{}'s oracle ({}) was written on another machine: the commands that run its tests are kept there",
525 e.key,
526 commit.get(..7).unwrap_or(&commit)
527 ),
528 ));
529 };
530 let w = written(e.wt_root, "HEAD", e.key);
531 let (contracts, by, at, untestable) = w.map_or_else(
532 || {
533 (
534 BTreeMap::new(),
535 "speccoder run".to_owned(),
536 BTreeMap::new(),
537 BTreeMap::new(),
538 )
539 },
540 |w| (w.contracts, w.by, w.states, w.untestable),
541 );
542 let mut current = oracle(frozen.tests, contracts, commit, by);
543 let gone = changed(e.project, ¤t);
544 if !gone.is_empty() {
545 return Err(stop(
546 "ORACLE-TAMPERED",
547 format!(
548 "{}'s oracle tests on the branch are not the frozen ones: {} — the task stops for a person, nothing is ticked",
549 e.key,
550 gone.join(", ")
551 ),
552 ));
553 }
554 current.at_oracle = first_states(e.wt_root, e.key).unwrap_or(at);
557 current.untestable = untestable;
558 current.reviewed = crate::pre_frozen::load(e.plane_dir, e.feature, e.key)
559 .ok()
560 .flatten()
561 .filter(|(r, _)| r.producer == speccoder_core::acceptance::Producer::Review)
562 .map(|(r, _)| (r, frozen.manifest_sha256.clone()));
563 let mut rulings = crate::acceptance_at::rulings_for(e.plane_dir, e.feature, e.key)
566 .map_err(|m| {
567 stop(
568 "ORACLE-TAMPERED",
569 format!("the rulings cannot be read: {m}"),
570 )
571 })?;
572 if e.fresh {
574 rulings = e
575 .scenarios
576 .iter()
577 .map(|s| crate::acceptance_at::Ruling {
578 feature: e.feature.to_owned(),
579 scenario: Some(s.id.clone()),
580 story: e.key.to_owned(),
581 task: String::new(),
582 reason: "fresh tests were asked for".to_owned(),
583 by: "you".to_owned(),
584 via: speccoder_core::actions::Via::Terminal,
585 at: 0,
586 })
587 .collect();
588 }
589 if rulings.is_empty() {
590 return Ok((current, false, notices));
591 }
592 if e.resuming {
595 notices.push(format!(
596 "a ruling on {}'s tests waits: the interrupted attempt resumes first",
597 e.key
598 ));
599 return Ok((current, false, notices));
600 }
601 return amend(e, current, &rulings).map(|o| (o, true, notices));
602 }
603 let reviewed = match crate::oracle::for_task(
605 e.plane_dir,
606 e.project,
607 e.wt_root,
608 e.format,
609 e.snapshot,
610 e.scenarios,
611 ) {
612 Some(crate::oracle::TaskReuse::Ready {
613 record,
614 files,
615 manifest,
616 ..
617 }) => {
618 notices.push(format!(
619 "acceptance tests frozen at review by {} ({}) for {}: no author call",
620 record.decided.by, record.decided.via, record.requirement
621 ));
622 let tests = crate::oracle::load_frozen(e.plane_dir, e.feature, e.key)
623 .map(|f| f.tests)
624 .unwrap_or_default();
625 let contracts = crate::oracle::load(e.plane_dir, e.feature, e.key)
626 .ok()
627 .flatten()
628 .map(|(d, _)| contracts_of(&d.expectations.sample1))
629 .unwrap_or_default();
630 (!tests.is_empty()).then(|| {
631 let by = record.decided.by.clone();
632 (tests, files, contracts, by, Some((*record, manifest)))
633 })
634 }
635 Some(crate::oracle::TaskReuse::Lapsed {
636 req,
637 frozen,
638 current,
639 }) => {
640 if crate::oracle::note_lapse(e.plane_dir, e.feature, &req, &frozen)
641 && let Some(p) = &e.options.progress
642 {
643 p.event(&crate::progress::Event::Oracle {
644 step: crate::progress::OracleEvent::Lapsed {
645 unit: e.feature.to_owned(),
646 requirement: req.clone(),
647 frozen_wording_sha256: frozen,
648 current_wording_sha256: current,
649 },
650 });
651 }
652 notices.push(format!(
653 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
654 ));
655 None
656 }
657 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
658 notices.push(format!("{req}: frozen oracle not present here — authoring"));
659 None
660 }
661 Some(crate::oracle::TaskReuse::Tampered(why)) => {
662 return Err(stop(
663 "ORACLE-TAMPERED",
664 format!("{why}: the task stops for a person, nothing is ticked"),
665 ));
666 }
667 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
668 return Err(stop(
669 "REVIEW-UNREADABLE",
670 format!(
671 "{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"
672 ),
673 ));
674 }
675 _ => None,
676 };
677 let mut spent = None;
678 let (tests, files, contracts, by, reviewed) = match reviewed {
679 Some(r) => r,
680 None => {
681 let w = crate::oracle::write_for_run(
682 e.project,
683 e.wt_root,
684 e.feature,
685 e.state,
686 e.key,
687 e.runner.clone(),
688 (e.rt, e.plane),
689 (&[], &[]),
690 "",
691 e.options,
692 )?;
693 spent.clone_from(&w.usage);
694 (
695 w.tests,
696 w.contents,
697 contracts_of(&w.contracts),
698 "speccoder run".to_owned(),
699 None,
700 )
701 }
702 };
703 for (path, content) in &files {
706 if let Ok(there) = std::fs::read_to_string(e.project.join(path))
707 && there != *content
708 {
709 return Err(stop(
710 "ORACLE-TAMPERED",
711 format!(
712 "{path} exists and differs from the confirmed test: the task stops for a person, nothing is ticked"
713 ),
714 ));
715 }
716 }
717 for (path, content) in &files {
718 let full = e.project.join(path);
719 let written = full
720 .parent()
721 .map_or(Ok(()), std::fs::create_dir_all)
722 .and_then(|()| std::fs::write(&full, content));
723 if let Err(err) = written {
724 return Err(unwritten(e.key, format!("{path}: {err}")));
725 }
726 }
727 let mut o = oracle(tests, contracts, String::new(), by);
728 o.usage = spent;
729 o.reviewed = reviewed;
730 o.untestable = crate::oracle::load(e.plane_dir, e.feature, e.key)
732 .ok()
733 .flatten()
734 .map(|(d, _)| {
735 d.untestable
736 .sample1
737 .into_iter()
738 .filter(|u| e.scenarios.iter().any(|s| s.id == u.scenario))
739 .map(|u| (u.scenario, u.why))
740 .collect()
741 })
742 .unwrap_or_default();
743 let now = states(e.project, &o, e.runner);
744 o.at_oracle = now.clone();
745 let message = commit_message(&o, &now, None, None, None);
746 let paths: Vec<String> = files
749 .iter()
750 .map(|(p, _)| {
751 e.project
752 .join(p)
753 .strip_prefix(e.wt_root)
754 .map_or_else(|_| p.clone(), |r| r.to_string_lossy().into_owned())
755 })
756 .collect();
757 o.commit = super::commit::commit_paths(e.wt_root, &message, &paths, true)
758 .map_err(|m| unwritten(e.key, format!("the oracle could not be committed: {m}")))?;
759 Ok((o, true, notices))
760}
761
762fn amend(
768 e: &Ensure<'_>,
769 mut o: StoryOracle,
770 rulings: &[crate::acceptance_at::Ruling],
771) -> Result<StoryOracle, Box<speccoder_core::CommandResult>> {
772 let mut ruled: Vec<String> = rulings.iter().filter_map(|r| r.scenario.clone()).collect();
773 let (old, kept): (Vec<FrozenTest>, Vec<FrozenTest>) = std::mem::take(&mut o.tests)
774 .into_iter()
775 .partition(|t| t.scenarios.iter().any(|s| ruled.contains(s)));
776 for s in old.iter().flat_map(|t| &t.scenarios) {
777 if !ruled.contains(s) {
778 ruled.push(s.clone());
779 }
780 }
781 let told: Vec<String> = rulings
782 .iter()
783 .map(|r| {
784 format!(
785 "{} ruled {}'s tests wrong: {}",
786 r.by,
787 r.scenario.as_deref().unwrap_or_default(),
788 r.reason
789 )
790 })
791 .collect();
792 let rel = |path: &str| {
793 e.project
794 .join(path)
795 .strip_prefix(e.wt_root)
796 .map_or_else(|_| path.to_owned(), |p| p.to_string_lossy().into_owned())
797 };
798 let old_paths: Vec<String> = old.iter().map(|t| rel(&t.path)).collect();
799 let restore = |r: Box<speccoder_core::CommandResult>| {
800 let mut args = vec!["checkout", "HEAD", "--"];
801 args.extend(old_paths.iter().map(String::as_str));
802 let _ = crate::git::Git::at(e.wt_root).run(&args);
803 r
804 };
805 for t in &old {
807 let _ = std::fs::remove_file(e.project.join(&t.path));
808 }
809 let w = crate::oracle::write_for_run(
810 e.project,
811 e.wt_root,
812 e.feature,
813 e.state,
814 e.key,
815 e.runner.clone(),
816 (e.rt, e.plane),
817 (&ruled, &told),
818 "",
819 e.options,
820 )
821 .map_err(restore)?;
822 o.usage = crate::pipeline::commit::add_usage(o.usage.as_ref(), w.usage.as_ref());
823 for (path, content) in &w.contents {
824 let full = e.project.join(path);
825 let written = full
826 .parent()
827 .map_or(Ok(()), std::fs::create_dir_all)
828 .and_then(|()| std::fs::write(&full, content));
829 if let Err(err) = written {
830 return Err(restore(unwritten(e.key, format!("{path}: {err}"))));
831 }
832 }
833 let new_paths: Vec<String> = w.tests.iter().map(|t| rel(&t.path)).collect();
834 o.tests = kept.into_iter().chain(w.tests).collect();
835 for (id, c) in contracts_of(&w.contracts) {
836 o.contracts.insert(id, c);
837 }
838 for u in w.untestable.iter().filter(|u| ruled.contains(&u.scenario)) {
839 o.untestable.insert(u.scenario.clone(), u.why.clone());
840 }
841 crate::oracle::refreeze(e.plane_dir, e.feature, e.key, o.tests.clone())
842 .map_err(|m| restore(unwritten(e.key, m)))?;
843 let now = states(e.project, &o, e.runner);
844 let message = commit_message(
845 &o,
846 &now,
847 Some(
848 &rulings
849 .iter()
850 .filter_map(|r| r.scenario.as_deref())
851 .collect::<Vec<_>>()
852 .join(", "),
853 ),
854 None,
855 None,
856 );
857 let paths: Vec<String> = old_paths.iter().chain(&new_paths).cloned().collect();
858 o.commit = super::commit::commit_paths(e.wt_root, &message, &paths, true).map_err(|m| {
859 restore(unwritten(
860 e.key,
861 format!("the amendment could not be committed: {m}"),
862 ))
863 })?;
864 let acted: Vec<String> = rulings.iter().filter_map(|r| r.scenario.clone()).collect();
865 crate::acceptance_at::acted_on(e.plane_dir, e.feature, &acted)
866 .map_err(|m| unwritten(e.key, m))?;
867 Ok(o)
868}
869
870fn first_states(wt_root: &Path, story: &str) -> Option<BTreeMap<String, ScenarioState>> {
872 let out = crate::git::Git::at(wt_root)
873 .line(&[
874 "log",
875 "--reverse",
876 &format!("--grep=^{ORACLE}: {story}$"),
877 "--format=%(trailers:key=Oracle-Scenarios,valueonly,separator=%x2c)",
878 "HEAD",
879 ])
880 .ok()?;
881 out.lines().next().map(parse_states)
882}
883
884fn parse_states(trailer: &str) -> BTreeMap<String, ScenarioState> {
886 trailer
887 .split(',')
888 .filter_map(|p| {
889 let (id, w) = p.trim().split_once('=')?;
890 let st = match w.trim() {
891 "green" => ScenarioState::Green,
892 "red" => ScenarioState::Red,
893 _ => ScenarioState::Untested,
894 };
895 Some((unesc(id.trim()), st))
896 })
897 .collect()
898}
899
900fn tests_of(wt_root: &Path, commit: &str) -> Option<usize> {
902 crate::git::Git::at(wt_root)
903 .line(&[
904 "log",
905 "-n1",
906 "--format=%(trailers:key=Oracle-Tests,valueonly)",
907 commit,
908 ])
909 .ok()
910 .and_then(|v| v.trim().parse().ok())
911}
912
913fn contracts_of(e: &[speccoder_core::oracle::Expectation]) -> BTreeMap<String, String> {
915 let mut out = BTreeMap::new();
916 for x in e {
917 out.entry(x.scenario.clone())
918 .or_insert_with(|| format!("{} → {}", x.given.trim(), x.expect.trim()));
919 }
920 out
921}
922
923#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
925pub(crate) struct TestRun {
926 pub(crate) passed: bool,
927 pub(crate) class: speccoder_core::verify::RedClass,
928 pub(crate) output: String,
930}
931
932#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
934pub(crate) struct Run {
935 pub(crate) states: BTreeMap<String, ScenarioState>,
936 pub(crate) tests: BTreeMap<String, TestRun>,
938}
939
940pub(crate) fn changed(project: &Path, oracle: &StoryOracle) -> Vec<String> {
943 oracle
944 .tests
945 .iter()
946 .filter(|t| {
947 std::fs::read(project.join(&t.path)).map_or(true, |b| {
948 format!("sha256:{}", crate::integrity::sha256_hex(&b)) != t.sha256
949 })
950 })
951 .map(|t| t.path.clone())
952 .collect()
953}
954
955pub(crate) fn states(
959 project: &Path,
960 oracle: &StoryOracle,
961 runner: &sandbox::Runner,
962) -> BTreeMap<String, ScenarioState> {
963 run(project, oracle, runner).states
964}
965
966pub(crate) fn run(project: &Path, oracle: &StoryOracle, runner: &sandbox::Runner) -> Run {
968 let tests: BTreeMap<String, TestRun> = oracle
969 .tests
970 .iter()
971 .map(|t| {
972 let (r, summary) = super::acceptance::acceptance_run(project, &t.argv, runner);
973 let class = speccoder_core::verify::classify_red(&r, summary.as_ref());
976 let run = TestRun {
977 passed: r.status == CheckStatus::Passed
978 && class == speccoder_core::verify::RedClass::Passed,
979 class,
980 output: format!("{}\n{}", r.stdout_tail, r.stderr_tail)
981 .trim()
982 .to_owned(),
983 };
984 (t.path.clone(), run)
985 })
986 .collect();
987 let passed: Vec<(&FrozenTest, bool)> = oracle
988 .tests
989 .iter()
990 .map(|t| (t, tests.get(&t.path).is_some_and(|r| r.passed)))
991 .collect();
992 let states = oracle
993 .scenarios
994 .iter()
995 .map(|s| {
996 let citing: Vec<bool> = passed
997 .iter()
998 .filter(|(t, _)| t.scenarios.contains(&s.id))
999 .map(|(_, ok)| *ok)
1000 .collect();
1001 let state = if citing.is_empty() {
1002 ScenarioState::Untested
1003 } else if citing.iter().all(|ok| *ok) {
1004 ScenarioState::Green
1005 } else {
1006 ScenarioState::Red
1007 };
1008 (s.id.clone(), state)
1009 })
1010 .collect();
1011 Run { states, tests }
1012}
1013
1014pub(crate) fn evidence(oracle: &StoryOracle, turned: &[String], before: &Run) -> serde_json::Value {
1018 let tests: Vec<&FrozenTest> = oracle
1019 .tests
1020 .iter()
1021 .filter(|t| t.scenarios.iter().any(|s| turned.contains(s)))
1022 .collect();
1023 let red: Vec<&TestRun> = tests
1024 .iter()
1025 .filter_map(|t| before.tests.get(&t.path))
1026 .filter(|r| r.class.is_red())
1027 .collect();
1028 let class = red
1031 .first()
1032 .map_or(speccoder_core::verify::RedClass::Unclassified, |r| r.class);
1033 serde_json::json!({
1034 "source": if oracle.decided_by == "speccoder run" { "authored" } else { "review" },
1036 "oracle": oracle.commit,
1037 "files": tests.iter().map(|t| t.path.clone()).collect::<Vec<_>>(),
1038 "tests": tests
1039 .iter()
1040 .map(|t| serde_json::json!({ "path": t.path, "scenarios": t.scenarios, "argv": t.argv }))
1041 .collect::<Vec<_>>(),
1042 "argv": tests.first().map(|t| t.argv.clone()).unwrap_or_default(),
1043 "red": true,
1044 "red_class": class,
1045 "red_output": red.first().map(|r| r.output.clone()).unwrap_or_default(),
1046 "green": true,
1047 "pre_frozen": oracle.reviewed.as_ref().map(|(r, m)| serde_json::json!({
1048 "manifest": m,
1049 "approval": r.approval_digest,
1050 "wording": r.wording_digest,
1051 "by": r.decided.by,
1052 "via": r.decided.via,
1053 })),
1054 })
1055}
1056
1057fn esc(id: &str) -> String {
1060 let mut out = String::new();
1061 for c in id.chars() {
1062 match c {
1063 '%' | ',' | '=' | ':' | ' ' | '(' | ')' | '\n' | '\r' => {
1064 for b in c.to_string().bytes() {
1065 out.push_str(&format!("%{b:02X}"));
1066 }
1067 }
1068 c => out.push(c),
1069 }
1070 }
1071 out
1072}
1073
1074fn unesc(word: &str) -> String {
1076 let b = word.as_bytes();
1077 let mut out = Vec::with_capacity(b.len());
1078 let mut i = 0;
1079 while i < b.len() {
1080 if b[i] == b'%'
1081 && let Some(h) = word.get(i + 1..i + 3)
1082 && let Ok(v) = u8::from_str_radix(h, 16)
1083 {
1084 out.push(v);
1085 i += 3;
1086 } else {
1087 out.push(b[i]);
1088 i += 1;
1089 }
1090 }
1091 String::from_utf8_lossy(&out).into_owned()
1092}
1093
1094pub(crate) fn states_trailer(order: &[String], states: &BTreeMap<String, ScenarioState>) -> String {
1096 order
1097 .iter()
1098 .map(|id| {
1099 let word = match states.get(id).copied().unwrap_or(ScenarioState::Untested) {
1100 ScenarioState::Green => "green",
1101 ScenarioState::Red => "red",
1102 ScenarioState::Untested => "untested",
1103 };
1104 format!("{}={word}", esc(id))
1105 })
1106 .collect::<Vec<_>>()
1107 .join(", ")
1108}
1109
1110pub(crate) fn commit_message(
1113 oracle: &StoryOracle,
1114 states: &BTreeMap<String, ScenarioState>,
1115 amends: Option<&str>,
1116 run: Option<&str>,
1117 usage: Option<&serde_json::Value>,
1118) -> String {
1119 let order = oracle.order();
1120 let red = order
1121 .iter()
1122 .filter(|id| states.get(*id) != Some(&ScenarioState::Green))
1123 .count();
1124 let title = match amends {
1125 Some(s) => format!("speccoder: oracle {} amended ({s})", oracle.story),
1126 None => format!(
1127 "speccoder: oracle {} — {} scenario(s), {red} to turn green",
1128 oracle.story,
1129 order.len()
1130 ),
1131 };
1132 let contracts: Vec<String> = order
1133 .iter()
1134 .filter_map(|id| {
1135 oracle
1136 .contracts
1137 .get(id)
1138 .map(|c| format!("{}: {}", esc(id), c.replace('\n', " ")))
1139 })
1140 .collect();
1141 let mut trailers = vec![
1142 format!("{ORACLE}: {}", oracle.story),
1143 format!("Oracle-Scenarios: {}", states_trailer(&order, states)),
1144 format!("Oracle-Tests: {}", oracle.tests.len()),
1145 format!("Oracle-By: {}", oracle.decided_by),
1146 ];
1147 for (id, why) in &oracle.untestable {
1148 trailers.push(format!(
1149 "Oracle-Untestable: {} ({})",
1150 esc(id),
1151 why.replace(['\n', '(', ')'], " ").trim()
1152 ));
1153 }
1154 if let Some(s) = amends {
1155 trailers.push(format!("Oracle-Amends: {s}"));
1156 }
1157 if let Some(r) = run.filter(|r| !r.is_empty()) {
1158 trailers.push(format!("Run: {r}"));
1159 }
1160 let usage = crate::attempts::usage_trailer(usage);
1161 if !usage.is_empty() {
1162 trailers.push(usage);
1163 }
1164 let body = if contracts.is_empty() {
1165 String::new()
1166 } else {
1167 format!("{}\n\n", contracts.join("\n"))
1168 };
1169 format!("{title}\n\n{body}{}\n", trailers.join("\n"))
1170}
1171
1172pub(crate) fn judge(
1177 oracle: &StoryOracle,
1178 before: &BTreeMap<String, ScenarioState>,
1179 after: &BTreeMap<String, ScenarioState>,
1180 links: &[String],
1181 checkpoint: bool,
1182 report: &mut speccoder_core::RunReport,
1183) {
1184 let untestable: Vec<String> = oracle.untestable.keys().cloned().collect();
1185 let p = speccoder_core::story::progress(
1186 &oracle.order(),
1187 before,
1188 after,
1189 links,
1190 checkpoint,
1191 &untestable,
1192 );
1193 let name = |ids: &[String]| -> Vec<String> {
1194 ids.iter()
1195 .map(|id| match oracle.contracts.get(id) {
1196 Some(c) => format!("{id} ({c})"),
1197 None => id.clone(),
1198 })
1199 .collect()
1200 };
1201 let failed = match &p {
1202 Progress::Regressed { scenarios } => Some(format!(
1203 "scenarios green before the attempt are red after it: {}",
1204 name(scenarios).join("; ")
1205 )),
1206 Progress::LinksOpen { scenarios } => Some(format!(
1207 "the scenarios this task names are not green: {}",
1208 name(scenarios).join("; ")
1209 )),
1210 Progress::StoryOpen { scenarios } => Some(format!(
1211 "the story's last task left scenarios red: {}",
1212 name(scenarios).join("; ")
1213 )),
1214 _ => None,
1215 };
1216 report.progress = Some(p.clone());
1217 match (report.verdict.take(), failed) {
1218 (Some(Verdict::Failed { mut reasons }), Some(why)) => {
1219 reasons.push(why);
1220 report.verdict = Some(Verdict::Failed { reasons });
1221 }
1222 (Some(v @ Verdict::Failed { .. }), None) => report.verdict = Some(v),
1223 (_, Some(why)) => report.verdict = Some(Verdict::Failed { reasons: vec![why] }),
1224 (v, None) => {
1225 report.verdict = match p {
1226 Progress::Verified { .. } => Some(Verdict::Verified),
1227 _ => v.map(|_| Verdict::RegressionOnly),
1228 };
1229 }
1230 }
1231}
1232
1233pub(crate) fn unchecked(report: &speccoder_core::RunReport) -> bool {
1236 let Some(v) = report.verification.as_ref() else {
1237 return false;
1238 };
1239 let as_before = v
1240 .comparison
1241 .as_ref()
1242 .map(|c| c.failing_as_before.clone())
1243 .unwrap_or_default();
1244 v.checks
1245 .iter()
1246 .any(|c| as_before.contains(&c.name) && c.tests.is_none())
1247}
1248
1249pub(crate) fn stories(
1252 tasks: &speccoder_core::snapshot::ParsedTasks,
1253 whole_unit: bool,
1254) -> Vec<(Option<&str>, Vec<&speccoder_core::snapshot::Task>)> {
1255 let mut out: Vec<(Option<&str>, Vec<&speccoder_core::snapshot::Task>)> = Vec::new();
1256 for t in &tasks.tasks {
1257 let label = if whole_unit { None } else { t.story.as_deref() };
1258 if !whole_unit && label.is_none() {
1259 continue;
1260 }
1261 match out.iter_mut().find(|(l, _)| *l == label) {
1262 Some((_, members)) => members.push(t),
1263 None => out.push((label, vec![t])),
1264 }
1265 }
1266 out
1267}
1268
1269pub(crate) fn story_tasks<'a>(
1271 members: &[&'a speccoder_core::snapshot::Task],
1272 unit_dir: &str,
1273 judged: &dyn Fn(&str) -> bool,
1274) -> Vec<speccoder_core::story::StoryTask<'a>> {
1275 members
1276 .iter()
1277 .map(|t| speccoder_core::story::StoryTask {
1278 id: &t.id,
1279 checks_only: super::acceptance::checks_only(t, unit_dir, judged),
1280 done: t.done,
1281 links: &t.scenarios,
1282 })
1283 .collect()
1284}
1285
1286struct Written {
1289 commit: String,
1290 by: String,
1291 states: BTreeMap<String, ScenarioState>,
1292 contracts: BTreeMap<String, String>,
1293 untestable: BTreeMap<String, String>,
1294}
1295
1296fn written(repo: &Path, rev: &str, story: &str) -> Option<Written> {
1297 let out = crate::git::Git::at(repo)
1298 .line(&[
1299 "log",
1300 "-n1",
1301 &format!("--grep=^{ORACLE}: {story}$"),
1302 "--format=%h%x1f%(trailers:key=Oracle-Scenarios,valueonly,separator=%x2c)%x1f%(trailers:key=Oracle-By,valueonly)%x1f%(trailers:key=Oracle-Untestable,valueonly,separator=%x1d)%x1f%b",
1303 rev,
1304 "--",
1305 ])
1306 .ok()?;
1307 let mut parts = out.splitn(5, '\u{1f}');
1308 let commit = parts.next()?.trim().to_owned();
1309 if commit.is_empty() {
1310 return None;
1311 }
1312 let states = parse_states(parts.next().unwrap_or_default());
1313 let by = parts.next().unwrap_or_default().trim().to_owned();
1314 let untestable = parts
1315 .next()
1316 .unwrap_or_default()
1317 .split('\u{1d}')
1318 .filter_map(|u| {
1319 let (id, why) = u.trim().split_once(' ')?;
1320 Some((
1321 unesc(id),
1322 why.trim_matches(|c| c == '(' || c == ')').to_owned(),
1323 ))
1324 })
1325 .collect();
1326 let contracts = parts
1327 .next()
1328 .unwrap_or_default()
1329 .lines()
1330 .take_while(|l| !l.trim().is_empty())
1333 .filter_map(|l| {
1334 let (id, c) = l.split_once(": ")?;
1335 Some((unesc(id), c.trim().to_owned()))
1336 })
1337 .collect();
1338 Some(Written {
1339 commit,
1340 by: if by.is_empty() {
1341 "speccoder run".to_owned()
1342 } else {
1343 by
1344 },
1345 states,
1346 contracts,
1347 untestable,
1348 })
1349}
1350
1351pub(crate) fn accepted_on_branch(repo: &Path, rev: &str) -> Vec<String> {
1358 crate::git::Git::at(repo)
1359 .run(&[
1360 "log",
1361 "--first-parent",
1362 "--format=%(trailers:key=Untestable-Accepted,valueonly,separator=%x1e)",
1363 rev,
1364 "--",
1365 ])
1366 .map(|b| String::from_utf8_lossy(&b).into_owned())
1367 .unwrap_or_default()
1368 .split(['\u{1e}', '\n'])
1369 .filter_map(|v| v.split_whitespace().next().map(str::to_owned))
1370 .collect()
1371}
1372
1373fn turned_by(
1374 repo: &Path,
1375 rev: &str,
1376 feature: &str,
1377 story: &str,
1378 digests: &BTreeMap<String, String>,
1379) -> BTreeMap<String, String> {
1380 let log = crate::git::Git::at(repo)
1381 .run(&[
1382 "log",
1383 "--first-parent",
1384 "--format=%(trailers:key=Spec-Task,valueonly,separator=%x20)%x1f%(trailers:key=Scenarios,valueonly,separator=%x20)%x1f%(trailers:key=Oracle,valueonly)%x1f%(trailers:key=Oracle-Amends,valueonly,separator=%x2c)%x1e",
1385 rev,
1386 "--",
1387 ])
1388 .map(|b| String::from_utf8_lossy(&b).into_owned())
1389 .unwrap_or_default();
1390 let mut out = BTreeMap::new();
1391 let mut settled: Vec<String> = Vec::new();
1392 for record in log.split('\u{1e}') {
1393 let f: Vec<&str> = record.trim_start_matches('\n').split('\u{1f}').collect();
1394 let [task, marked, oracle, amends] = f[..] else {
1395 continue;
1396 };
1397 if oracle.trim() == story {
1398 let amended: Vec<String> = amends
1399 .split(',')
1400 .map(|a| a.trim().to_owned())
1401 .filter(|a| !a.is_empty())
1402 .collect();
1403 if amended.is_empty() {
1405 break;
1406 }
1407 settled.extend(amended);
1408 continue;
1409 }
1410 let Some(task) = task.trim().strip_prefix(&format!("{feature}/")) else {
1411 continue;
1412 };
1413 for token in marked.split(|c: char| c == ',' || c.is_whitespace()) {
1414 let d = token.rsplit('@').next().unwrap_or_default();
1415 if let Some((id, _)) = digests.iter().find(|(_, dg)| *dg == d)
1416 && !settled.contains(id)
1417 && !out.contains_key(id)
1418 {
1419 out.insert(id.clone(), task.to_owned());
1420 }
1421 }
1422 }
1423 out
1424}
1425
1426pub(crate) struct StoryView<'a> {
1428 pub(crate) format: &'a dyn crate::format::Adapter,
1429 pub(crate) snapshot: &'a speccoder_core::snapshot::ProjectSnapshot,
1430 pub(crate) feature: &'a str,
1431 pub(crate) unit_dir: &'a str,
1432 pub(crate) tasks: &'a speccoder_core::snapshot::ParsedTasks,
1433 pub(crate) repo: &'a Path,
1436 pub(crate) rev: &'a str,
1437}
1438
1439pub(crate) fn story_view(
1444 v: &StoryView<'_>,
1445) -> (
1446 BTreeMap<String, speccoder_core::story::Judged>,
1447 Vec<speccoder_core::story::StoryScenarios>,
1448) {
1449 use speccoder_core::story::{OracleMark, ScenarioMark, StoryScenarios, checkpoint, judged};
1450 let whole_unit = v.format.kind() == speccoder_core::spec::FormatKind::Openspec;
1451 let rules = v.format.rules().judged;
1452 let mut out = BTreeMap::new();
1453 let mut shown = Vec::new();
1454 if !whole_unit {
1456 for t in v.tasks.tasks.iter().filter(|t| t.story.is_none()) {
1457 let scenarios = v.format.scenarios(v.snapshot, &t.id);
1458 if let Some(j) = super::acceptance::outside_story(t, &scenarios, v.unit_dir, &rules) {
1459 out.insert(t.id.clone(), j);
1460 }
1461 }
1462 }
1463 for (label, members) in stories(v.tasks, whole_unit) {
1464 let Some(first) = members.first() else {
1465 continue;
1466 };
1467 let scenarios = v.format.scenarios(v.snapshot, &first.id);
1468 if scenarios.is_empty() {
1469 continue;
1470 }
1471 let key = label.map_or_else(|| v.feature.to_owned(), str::to_owned);
1472 let tasks = story_tasks(&members, v.unit_dir, &rules);
1473 let ids: Vec<String> = scenarios.iter().map(|s| s.id.clone()).collect();
1474 for t in &members {
1475 if let Some(j) = judged(&tasks, &t.id, &ids) {
1476 out.insert(t.id.clone(), j);
1477 }
1478 }
1479 let oracle = written(v.repo, v.rev, &key);
1480 let by_digest: BTreeMap<String, String> = scenarios
1481 .iter()
1482 .map(|s| (s.id.clone(), super::commit::scenario_digest(&s.id, &s.text)))
1483 .collect();
1484 let turned = turned_by(v.repo, v.rev, v.feature, &key, &by_digest);
1485 let marks: Vec<ScenarioMark> = scenarios
1486 .iter()
1487 .map(|s| {
1488 let turned_by = turned.get(&s.id).cloned();
1489 let at_oracle = oracle.as_ref().and_then(|o| o.states.get(&s.id).copied());
1490 let state = match (&turned_by, at_oracle) {
1491 (Some(_), _) | (None, Some(ScenarioState::Green)) => ScenarioState::Green,
1492 (None, Some(ScenarioState::Untested)) => ScenarioState::Untested,
1493 _ => ScenarioState::Red,
1494 };
1495 ScenarioMark {
1496 id: s.id.clone(),
1497 state,
1498 turned_by,
1499 contract: oracle
1500 .as_ref()
1501 .and_then(|o| o.contracts.get(&s.id).cloned()),
1502 }
1503 })
1504 .collect();
1505 let done = members.iter().all(|t| t.done);
1506 let accepted = accepted_on_branch(v.repo, v.rev);
1509 let proven = done
1510 && marks
1511 .iter()
1512 .all(|m| m.turned_by.is_some() || accepted.contains(&m.id));
1513 let before_oracle = done && oracle.is_none();
1514 shown.push(StoryScenarios {
1515 story: key,
1516 scenarios: marks,
1517 checkpoint: checkpoint(&tasks).map(str::to_owned),
1518 oracle: oracle.map(|o| OracleMark {
1519 commit: o.commit,
1520 decided_by: o.by,
1521 }),
1522 proven,
1523 before_oracle,
1524 finished: done,
1525 });
1526 }
1527 (out, shown)
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533
1534 fn oracle() -> StoryOracle {
1535 StoryOracle {
1536 story: "US1".into(),
1537 scenarios: (1..=2)
1538 .map(|k| ScenarioRef {
1539 id: format!("US1-{k}"),
1540 text: String::new(),
1541 })
1542 .collect(),
1543 tests: Vec::new(),
1544 contracts: [(
1545 "US1-1".to_owned(),
1546 "a table on stdin → its CSV on stdout".to_owned(),
1547 )]
1548 .into(),
1549 commit: "abc".into(),
1550 decided_by: "speccoder run".into(),
1551 held_out: None,
1552 held_out_summary: None,
1553 usage: None,
1554 reviewed: None,
1555 at_oracle: BTreeMap::new(),
1556 untestable: BTreeMap::new(),
1557 }
1558 }
1559
1560 #[test]
1561 fn a_check_failing_before_and_after_without_per_test_results_hides_regressions() {
1562 use speccoder_core::verify::{CheckResult, Comparison, VerifyReport};
1563 let check =
1564 |name: &str, tests: Option<Vec<speccoder_core::verify::TestResult>>| CheckResult {
1565 name: name.to_owned(),
1566 status: CheckStatus::Failed,
1567 tests,
1568 ..serde_json::from_value(serde_json::json!({
1569 "name": name, "argv": [], "status": "failed", "exit_code": 1, "duration_ms": 0,
1570 "junit": false, "stdout_tail": "", "stderr_tail": "", "truncated": false
1571 }))
1572 .unwrap()
1573 };
1574 let report = |c: CheckResult| speccoder_core::RunReport {
1575 verification: Some(VerifyReport {
1576 root: String::new(),
1577 tier: "fast".to_owned(),
1578 checks: vec![c],
1579 comparison: Some(Comparison {
1580 failing_as_before: vec!["test".to_owned()],
1581 ..Comparison::default()
1582 }),
1583 passed: true,
1584 }),
1585 ..super::super::outcome::bare_report("001", "T001", "/w", "completed")
1586 };
1587 assert!(unchecked(&report(check("test", None))));
1588 assert!(!unchecked(&report(check("test", Some(Vec::new())))));
1589 }
1590
1591 #[test]
1592 fn the_oracle_commit_names_its_story_states_and_contracts() {
1593 let o = oracle();
1594 let st: BTreeMap<String, ScenarioState> = [
1595 ("US1-1".to_owned(), ScenarioState::Red),
1596 ("US1-2".to_owned(), ScenarioState::Untested),
1597 ]
1598 .into();
1599 let m = commit_message(&o, &st, None, Some("run_1"), None);
1600 assert!(m.starts_with("speccoder: oracle US1 — 2 scenario(s), 2 to turn green\n\nUS1-1: a table on stdin → its CSV on stdout\n\n"), "{m}");
1601 assert!(m.contains("\nOracle: US1\nOracle-Scenarios: US1-1=red, US1-2=untested\nOracle-Tests: 0\nOracle-By: speccoder run\nRun: run_1\n"), "{m}");
1602 }
1603
1604 #[test]
1605 fn a_named_scenario_survives_the_trailer() {
1606 let id = "Login: wrong password (3 times), 100% = locked";
1607 assert_eq!(unesc(&esc(id)), id);
1608 let read = parse_states(&format!("{}=green, US1-2=red", esc(id)));
1609 assert_eq!(read.get(id), Some(&ScenarioState::Green));
1610 assert_eq!(read.get("US1-2"), Some(&ScenarioState::Red));
1611 }
1612}