1use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use agentplane::model::ModelProvider;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use speccoder_core::acceptance::{Decided, PreFrozenAcceptance, Producer, SealedRef, VisibleTest};
22use speccoder_core::actions::Via;
23use speccoder_core::context::{ContextEntry, ContextKind};
24use speccoder_core::ladder::Rung;
25use speccoder_core::oracle::{
26 Ambiguity, AuthoredBy, Expectation, HeldOutSummary, OracleDiscarded, OracleOp, OracleRejected,
27 OracleState, OracleTest, OracleView, Reading, Resolution, Untestable,
28};
29use speccoder_core::snapshot::ProjectSnapshot;
30use speccoder_core::verify::{RedClass, classify_red};
31use speccoder_core::{CommandData, CommandResult, CommandStatus};
32
33use crate::format::{Adapter, OracleRequirement};
34use crate::integrity::sha256_hex;
35use crate::{sandbox, substrate, tools, worktree};
36
37pub const DIR: &str = "oracle";
39
40pub const LIMIT: usize = 4;
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Draft {
46 pub requirement: DraftRequirement,
48 pub samples: u8,
50 pub tests: Vec<DraftTest>,
52 pub discarded: Vec<OracleDiscarded>,
54 pub rejected: Vec<OracleRejected>,
56 pub expectations: Samples<Expectation>,
58 pub untestable: Samples<Untestable>,
60 pub ambiguities: Vec<Ambiguity>,
62 pub held_out: HeldOutSummary,
64 pub sealed: Option<SealedRef>,
66 pub notes: Vec<String>,
68 pub runs: Vec<String>,
70 pub context: Vec<ContextEntry>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct DraftRequirement {
78 pub key: String,
80 pub title: String,
82 pub text: String,
84 pub sha256: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct DraftTest {
91 pub path: String,
93 pub sha256: String,
95 pub scenarios: Vec<String>,
97 pub argv: Vec<String>,
99 pub red_class: RedClass,
101 pub authored_by: AuthoredBy,
103 pub edited_by: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(bound(deserialize = "T: Deserialize<'de>"))]
110pub struct Samples<T> {
111 pub sample1: Vec<T>,
113 pub sample2: Vec<T>,
115}
116
117impl<T> Default for Samples<T> {
118 fn default() -> Self {
119 Self {
120 sample1: Vec::new(),
121 sample2: Vec::new(),
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct OracleEntry {
129 pub requirement_sha256: String,
131 pub manifest_sha256: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub held_out_sha256: Option<String>,
136 pub by: String,
138 pub via: Via,
140 pub at: u64,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Frozen {
148 pub requirement_sha256: String,
150 pub wording: String,
152 pub manifest_sha256: String,
154 pub tests: Vec<FrozenTest>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct FrozenTest {
161 pub path: String,
163 pub sha256: String,
165 pub argv: Vec<String>,
167 pub scenarios: Vec<String>,
169}
170
171pub type Loaded = (Draft, Vec<(String, String)>);
173
174type Kept = (String, String, Vec<String>, Vec<String>);
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Tampered(pub String);
180
181fn slug(key: &str) -> String {
182 key.chars()
183 .map(|c| {
184 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
185 c
186 } else {
187 '_'
188 }
189 })
190 .collect()
191}
192
193#[must_use]
195pub fn dir(project_state: &Path, unit: &str, key: &str) -> PathBuf {
196 project_state.join(DIR).join(unit).join(slug(key))
197}
198
199fn now() -> u64 {
200 std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .map_or(0, |d| d.as_secs())
203}
204
205fn digest_of(text: &str) -> String {
206 format!("sha256:{}", sha256_hex(text.as_bytes()))
207}
208
209fn write_atomic(file: &Path, text: &str) -> Result<(), String> {
210 if let Some(parent) = file.parent() {
211 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
212 }
213 let tmp = file.with_extension("json.tmp");
214 std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
215 std::fs::rename(&tmp, file).map_err(|e| format!("{}: {e}", file.display()))
216}
217
218pub fn store(
221 project_state: &Path,
222 unit: &str,
223 draft: &Draft,
224 contents: &[(String, String)],
225) -> Result<(), String> {
226 let d = dir(project_state, unit, &draft.requirement.key);
227 let _ = std::fs::remove_dir_all(d.join("files"));
228 for t in &draft.tests {
229 let content = contents
230 .iter()
231 .find(|(p, _)| *p == t.path)
232 .map(|(_, c)| c)
233 .ok_or_else(|| format!("{} has no content", t.path))?;
234 if digest_of(content) != t.sha256 {
235 return Err(format!("{} does not match its digest", t.path));
236 }
237 let file = d.join("files").join(&t.path);
238 if let Some(parent) = file.parent() {
239 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
240 }
241 std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
242 }
243 let text = serde_json::to_string_pretty(draft).map_err(|e| e.to_string())?;
244 write_atomic(&d.join("draft.json"), &text)
245}
246
247pub fn load(project_state: &Path, unit: &str, key: &str) -> Result<Option<Loaded>, Tampered> {
250 let d = dir(project_state, unit, key);
251 let Ok(text) = std::fs::read_to_string(d.join("draft.json")) else {
252 return Ok(None);
253 };
254 let draft: Draft = serde_json::from_str(&text)
255 .map_err(|e| Tampered(format!("the oracle draft of {key} is not valid: {e}")))?;
256 let mut contents = Vec::new();
257 for t in &draft.tests {
258 let content = std::fs::read_to_string(d.join("files").join(&t.path))
259 .map_err(|_| Tampered(format!("the test {} of {key} is missing", t.path)))?;
260 if digest_of(&content) != t.sha256 {
261 return Err(Tampered(format!("the test {} of {key} changed", t.path)));
262 }
263 contents.push((t.path.clone(), content));
264 }
265 Ok(Some((draft, contents)))
266}
267
268#[must_use]
270pub fn load_frozen(project_state: &Path, unit: &str, key: &str) -> Option<Frozen> {
271 std::fs::read_to_string(dir(project_state, unit, key).join("frozen.json"))
272 .ok()
273 .and_then(|t| serde_json::from_str(&t).ok())
274}
275
276#[must_use]
279pub fn manifest_digest<'a>(
280 tests: impl IntoIterator<Item = (&'a str, &'a str, &'a [String])>,
281) -> String {
282 let mut lines: Vec<String> = tests
283 .into_iter()
284 .map(|(path, sha, argv)| {
285 format!(
286 "{path}={sha} argv={}",
287 serde_json::to_string(argv).unwrap_or_default()
288 )
289 })
290 .collect();
291 lines.sort();
292 digest_of(&lines.join("\n"))
293}
294
295impl Draft {
296 pub fn red(&self) -> impl Iterator<Item = &DraftTest> {
298 self.tests.iter().filter(|t| t.red_class.is_red())
299 }
300
301 #[must_use]
303 pub fn manifest(&self) -> Option<String> {
304 let red: Vec<&DraftTest> = self.red().collect();
305 (!red.is_empty()).then(|| {
306 manifest_digest(
307 red.iter()
308 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
309 )
310 })
311 }
312
313 pub fn open_ambiguities(&self) -> impl Iterator<Item = &Ambiguity> {
315 self.ambiguities.iter().filter(|a| a.open())
316 }
317
318 fn all_notes(&self) -> Vec<String> {
320 self.rejected
321 .iter()
322 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
323 .chain(self.notes.iter().cloned())
324 .collect()
325 }
326}
327
328#[derive(Debug, Clone, Copy)]
330pub struct ViewConfig {
331 pub samples: u8,
333 pub strength_target: Option<u8>,
335 pub held_out: bool,
337 pub acceptance: bool,
339}
340
341impl ViewConfig {
342 #[must_use]
344 pub fn at(root: &Path) -> Self {
345 let rc = crate::verify::find_config(root)
346 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
347 .and_then(|t| substrate::run_config::parse(&t).ok())
348 .unwrap_or_default();
349 Self::of(&rc)
350 }
351
352 #[must_use]
354 pub fn of(rc: &substrate::run_config::RunConfig) -> Self {
355 Self {
356 samples: rc.oracle_samples,
357 strength_target: rc.strength.min.map(|m| {
358 let percent = (m * 100.0).round().clamp(0.0, 100.0);
360 format!("{percent:.0}").parse().unwrap_or(0)
361 }),
362 held_out: rc.held_out,
363 acceptance: rc.acceptance,
364 }
365 }
366}
367
368fn test_view(t: &DraftTest, content: &str) -> OracleTest {
369 OracleTest {
370 path: t.path.clone(),
371 content: content.to_owned(),
372 scenarios: t.scenarios.clone(),
373 red_class: crate::pipeline::class_word(t.red_class),
374 red: t.red_class.is_red(),
375 authored_by: t.authored_by,
376 edited_by: t.edited_by.clone(),
377 }
378}
379
380#[must_use]
384pub fn view(
385 project_state: &Path,
386 unit: &str,
387 req: &OracleRequirement,
388 entry: Option<&OracleEntry>,
389 cfg: ViewConfig,
390) -> OracleView {
391 let digest = req.digest();
392 let mut v = OracleView {
393 req: req.key.clone(),
394 title: req.title.clone(),
395 scenarios: req.scenarios.clone(),
396 state: OracleState::None,
397 frozen_on: None,
398 frozen_by: None,
399 frozen_at: None,
400 tests: Vec::new(),
401 discarded: Vec::new(),
402 rejected: Vec::new(),
403 uncovered: req.scenarios.iter().map(|s| s.id.clone()).collect(),
404 untestable: Vec::new(),
405 ambiguities: Vec::new(),
406 held_out: HeldOutSummary {
407 state: if cfg.held_out { "absent" } else { "off" }.to_owned(),
408 count: 0,
409 manifest_sha256: None,
410 reason: None,
411 discarded: Vec::new(),
412 },
413 strength_target: cfg.strength_target,
414 calls: cfg.samples,
415 ambiguity_check: cfg.samples >= 2,
416 manifest_sha256: None,
417 error: (!cfg.acceptance)
418 .then(|| "acceptance tests are off ([acceptance] generate = false)".to_owned()),
419 };
420 let loaded = load(project_state, unit, &req.key);
421 let draft = match loaded {
422 Ok(d) => d,
423 Err(t) => {
424 v.state = OracleState::Failed;
425 v.error = Some(t.0);
426 None
427 }
428 };
429 let current = draft
430 .as_ref()
431 .filter(|(d, _)| d.requirement.sha256 == digest);
432 if let Some((d, contents)) = current {
433 let content = |p: &str| {
434 contents
435 .iter()
436 .find(|(q, _)| q == p)
437 .map(|(_, c)| c.as_str())
438 .unwrap_or_default()
439 };
440 v.tests = d
441 .tests
442 .iter()
443 .map(|t| test_view(t, content(&t.path)))
444 .collect();
445 v.discarded.clone_from(&d.discarded);
446 v.rejected.clone_from(&d.rejected);
447 v.untestable.clone_from(&d.untestable.sample1);
448 v.ambiguities.clone_from(&d.ambiguities);
449 v.held_out = if cfg.held_out {
450 d.held_out.clone()
451 } else {
452 HeldOutSummary {
453 state: "off".to_owned(),
454 count: 0,
455 manifest_sha256: None,
456 reason: None,
457 discarded: Vec::new(),
458 }
459 };
460 v.manifest_sha256 = d.manifest();
461 let covered: Vec<&String> = d.red().flat_map(|t| t.scenarios.iter()).collect();
462 v.uncovered.retain(|s| !covered.contains(&s));
463 v.state = if d.open_ambiguities().next().is_some() {
464 OracleState::Ambiguous
465 } else if d.red().next().is_none() {
466 OracleState::NotRed
467 } else {
468 OracleState::Written
469 };
470 } else if draft.is_some() {
471 v.error = Some("written for an earlier wording: write them again".to_owned());
472 }
473 if let Some(e) = entry {
474 if e.requirement_sha256 == digest {
475 v.frozen_by = Some(e.by.clone());
476 v.frozen_at = Some(e.at);
477 v.state = match crate::pre_frozen::load(project_state, unit, &req.key) {
478 Ok(Some((rec, files))) => {
479 let drafted = current.map(|(d, _)| d);
482 v.tests = rec
483 .visible
484 .iter()
485 .map(|t| {
486 let same = drafted.and_then(|d| {
487 d.tests
488 .iter()
489 .find(|x| x.path == t.path && x.sha256 == t.sha256)
490 });
491 OracleTest {
492 path: t.path.clone(),
493 content: files
494 .iter()
495 .find(|(p, _)| *p == t.path)
496 .map(|(_, c)| c.clone())
497 .unwrap_or_default(),
498 scenarios: t.cites.clone(),
499 red_class: same.map_or_else(
500 || crate::pipeline::class_word(RedClass::Assertion),
501 |x| crate::pipeline::class_word(x.red_class),
502 ),
503 red: true,
504 authored_by: same.map_or(AuthoredBy::Model, |x| x.authored_by),
505 edited_by: same.and_then(|x| x.edited_by.clone()),
506 }
507 })
508 .collect();
509 v.manifest_sha256 = Some(e.manifest_sha256.clone());
510 let covered: Vec<&String> =
511 rec.visible.iter().flat_map(|t| t.cites.iter()).collect();
512 v.uncovered = req
513 .scenarios
514 .iter()
515 .map(|s| s.id.clone())
516 .filter(|s| !covered.contains(&s))
517 .collect();
518 if cfg.held_out {
519 v.held_out = match &rec.held_out_sealed {
520 Some(h) => HeldOutSummary {
521 state: "kept".to_owned(),
522 count: h.count,
523 manifest_sha256: Some(format!("sha256:{}", h.manifest_sha256)),
524 reason: None,
525 discarded: Vec::new(),
526 },
527 None => HeldOutSummary {
528 state: "absent".to_owned(),
529 count: 0,
530 manifest_sha256: None,
531 reason: Some("none_kept".to_owned()),
532 discarded: Vec::new(),
533 },
534 };
535 }
536 OracleState::Frozen
537 }
538 Ok(None) => OracleState::FrozenElsewhere,
539 Err(t) => {
540 v.error = Some(format!("the frozen set was tampered with: {}", t.0));
541 OracleState::Frozen
542 }
543 };
544 } else if current.is_none() {
545 v.state = OracleState::Lapsed;
546 v.frozen_on = load_frozen(project_state, unit, &req.key).map(|f| f.wording);
547 v.frozen_by = Some(e.by.clone());
548 v.frozen_at = Some(e.at);
549 }
550 } else if let Ok(Some((rec, _))) = crate::pre_frozen::load(project_state, unit, &req.key)
551 && rec.producer == Producer::Run
552 && rec.is_current(&digest)
553 {
554 v.state = OracleState::Frozen;
556 v.frozen_by = Some(rec.decided.by.clone());
557 v.frozen_at = Some(rec.decided.at);
558 }
559 v
560}
561
562pub fn open_ambiguities(
569 project_state: &Path,
570 unit: &str,
571 requirements: &[OracleRequirement],
572) -> Result<Vec<(String, String)>, Tampered> {
573 let mut out = Vec::new();
574 for r in requirements {
575 let Some((d, _)) = load(project_state, unit, &r.key)? else {
576 continue;
577 };
578 if d.requirement.sha256 != r.digest() {
579 continue;
580 }
581 out.extend(
582 d.open_ambiguities()
583 .map(|a| (d.requirement.key.clone(), a.scenario.clone())),
584 );
585 }
586 Ok(out)
587}
588
589#[must_use]
591pub fn rung(format: &dyn Adapter, root: &Path, unit: &str) -> Rung {
592 speccoder_core::ladder::rung_of(unit, &format.standing_units(root))
593}
594
595#[derive(Debug, Clone)]
597pub struct Request<'a> {
598 pub req: &'a str,
600 pub op: OracleOp,
602 pub path: Option<&'a str>,
604 pub scenario: Option<&'a str>,
606 pub reading: Option<Reading>,
608 pub text: Option<&'a str>,
610}
611
612impl Request<'_> {
613 #[must_use]
615 pub fn needs_model(&self) -> bool {
616 matches!(self.op, OracleOp::Write | OracleOp::Again)
617 || (self.op == OracleOp::Reading && self.reading == Some(Reading::B))
618 }
619}
620
621struct Ctx {
623 root: PathBuf,
624 feature: String,
625 dir: String,
626 format: &'static dyn Adapter,
627 snapshot: ProjectSnapshot,
628 plane_dir: PathBuf,
629 config: substrate::run_config::RunConfig,
630 sandbox: sandbox::Config,
631 worktree: crate::prepare::Config,
632 state: PathBuf,
634 policy: tools::policy::Policy,
635 model: Option<substrate::model_config::ModelConfig>,
636}
637
638fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
639 CommandResult::new("review", status, message).with_code(code)
640}
641
642fn open(path: &Path, feature: Option<&str>, state: &Path) -> Result<Ctx, Box<CommandResult>> {
643 let inspected = crate::inspect(path, feature);
644 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
645 return Err(Box::new(CommandResult::carried("review", &inspected)));
646 };
647 let snapshot = (**snapshot).clone();
648 let Some(f) = snapshot.feature.as_ref() else {
649 return Err(Box::new(CommandResult::new(
650 "review",
651 CommandStatus::SelectionNeeded,
652 inspected.message(),
653 )));
654 };
655 let root = PathBuf::from(&snapshot.root);
656 let config_root = crate::verify::find_config(&root).ok_or_else(|| {
657 Box::new(refuse(
658 CommandStatus::ConfigError,
659 "CONFIG-MISSING",
660 "the project has no speccoder.toml",
661 ))
662 })?;
663 let text =
664 std::fs::read_to_string(config_root.join(crate::verify::config::FILE)).unwrap_or_default();
665 let config = substrate::run_config::parse(&text).map_err(|e| {
666 Box::new(refuse(
667 CommandStatus::ConfigError,
668 "CONFIG-INVALID",
669 &format!("speccoder.toml: {e}"),
670 ))
671 })?;
672 let sandbox = sandbox::parse(&text).map_err(|e| {
673 Box::new(refuse(
674 CommandStatus::ConfigError,
675 "CONFIG-INVALID",
676 &format!("speccoder.toml: {e}"),
677 ))
678 })?;
679 let worktree_config = crate::prepare::parse(&text).map_err(|e| {
680 Box::new(refuse(
681 CommandStatus::ConfigError,
682 "CONFIG-INVALID",
683 &format!("speccoder.toml: {e}"),
684 ))
685 })?;
686 let format = crate::format::of(&snapshot);
687 let policy =
688 tools::policy::Policy::new(&crate::verify::check_argvs(&config_root), &config.allow)
689 .with_prefixes(config.allow_prefix.clone())
690 .rules(format.rules());
691 let model = substrate::model_config::parse(&text).ok().flatten();
692 Ok(Ctx {
693 feature: f.feature.name.clone(),
694 dir: f.feature.dir.clone(),
695 plane_dir: worktree::project_state_dir(state, &root),
696 root,
697 format,
698 snapshot,
699 config,
700 sandbox,
701 worktree: worktree_config,
702 state: state.to_path_buf(),
703 policy,
704 model,
705 })
706}
707
708fn project_in(root: &Path, worktree_root: &Path) -> PathBuf {
710 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
711 let top = crate::git::Git::at(root)
712 .toplevel()
713 .ok()
714 .flatten()
715 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
716 match canonical(root).strip_prefix(&top) {
717 Ok(rel) if !rel.as_os_str().is_empty() => worktree_root.join(rel),
718 _ => worktree_root.to_path_buf(),
719 }
720}
721
722pub(crate) struct UnitLock(PathBuf);
726
727impl UnitLock {
728 pub(crate) fn take(project_state: &Path, unit: &str) -> Result<Self, String> {
729 let path = project_state.join(DIR).join(format!("{unit}.lock"));
730 if let Some(parent) = path.parent() {
731 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
732 }
733 if std::fs::metadata(&path)
735 .ok()
736 .and_then(|m| m.modified().ok())
737 .and_then(|m| m.elapsed().ok())
738 .is_some_and(|age| age.as_secs() > 3600)
739 {
740 let _ = std::fs::remove_file(&path);
741 }
742 std::fs::OpenOptions::new()
743 .write(true)
744 .create_new(true)
745 .open(&path)
746 .map(|_| Self(path))
747 .map_err(|_| {
748 format!("{unit}'s tests are being written or frozen elsewhere right now: look again when that ends")
749 })
750 }
751}
752
753impl Drop for UnitLock {
754 fn drop(&mut self) {
755 let _ = std::fs::remove_file(&self.0);
756 }
757}
758
759fn stage(options: &crate::RunOptions, req: &str, stage: &str, n: u32, of: u32) {
761 if let Some(p) = &options.progress {
762 p.event(&crate::progress::Event::Oracle {
763 step: crate::progress::OracleEvent::Stage {
764 requirement: req.to_owned(),
765 stage: stage.to_owned(),
766 n,
767 of,
768 },
769 });
770 }
771}
772
773#[must_use]
776pub fn tests(
777 path: &Path,
778 feature: Option<&str>,
779 request: &Request<'_>,
780 via: Via,
781 state: Option<&Path>,
782 options: &crate::RunOptions,
783) -> CommandResult {
784 let Some(state) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
785 return refuse(
786 CommandStatus::ConfigError,
787 "NO-STATE-DIR",
788 "no state directory (set SPECCODER_STATE_DIR or HOME)",
789 );
790 };
791 if !request.needs_model() {
792 return tests_with(path, feature, request, via, &state, None, options);
793 }
794 let ctx = match open(path, feature, &state) {
795 Ok(c) => c,
796 Err(r) => return *r,
797 };
798 if let Err(r) = check(&ctx, request) {
799 return *r;
800 }
801 let Some(model) = ctx.model.as_ref() else {
802 return refuse(
803 CommandStatus::ConfigError,
804 "CONFIG",
805 "speccoder.toml has no [model] section: the test author needs a model",
806 );
807 };
808 match substrate::model_config::open_driver(model) {
809 Ok((provider, _)) => {
810 tests_with(path, feature, request, via, &state, Some(provider), options)
811 }
812 Err(e) => substrate::model_config::refused("review", &e),
813 }
814}
815
816#[must_use]
818pub fn tests_with(
819 path: &Path,
820 feature: Option<&str>,
821 request: &Request<'_>,
822 via: Via,
823 state: &Path,
824 provider: Option<Arc<dyn ModelProvider>>,
825 options: &crate::RunOptions,
826) -> CommandResult {
827 let ctx = match open(path, feature, state) {
828 Ok(c) => c,
829 Err(r) => return *r,
830 };
831 let req = match check(&ctx, request) {
832 Ok(r) => r,
833 Err(r) => return *r,
834 };
835 let _lock = match UnitLock::take(&ctx.plane_dir, &ctx.feature) {
836 Ok(l) => l,
837 Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e),
838 };
839 let who = crate::approvals::Who::local_in(&ctx.root, via);
840 let done = match request.op {
841 OracleOp::Write => write(&ctx, &req, provider, options),
842 OracleOp::Reject => reject(&ctx, &req, request, &who.name),
843 OracleOp::Again => again(&ctx, &req, request, &who.name, provider, options),
844 OracleOp::Edit => edit(&ctx, &req, request, &who.name),
845 OracleOp::Reading => reading(&ctx, &req, request, &who.name, provider, options),
846 };
847 match done {
848 Ok(message) => {
849 let r = crate::review::report_in(path, feature, Some(state));
850 let status = r.status();
851 r.reworded(status, &message)
852 }
853 Err(r) => *r,
854 }
855}
856
857fn check(ctx: &Ctx, request: &Request<'_>) -> Result<OracleRequirement, Box<CommandResult>> {
860 let unit_rung = rung(ctx.format, &ctx.root, &ctx.feature);
861 if unit_rung != Rung::Feature {
862 return Err(Box::new(
863 refuse(
864 CommandStatus::UsageError,
865 "REVIEW-TESTS-OWN-PIPELINE",
866 &format!(
867 "{} is a {} unit: its tests are written and confirmed by its own pipeline",
868 ctx.feature,
869 if unit_rung == Rung::Quick {
870 "quick-change"
871 } else {
872 "bug"
873 }
874 ),
875 )
876 .with_fix(vec![if unit_rung == Rung::Quick {
877 "speccoder do \"…\"".to_owned()
878 } else {
879 format!("speccoder bug confirm {}/<task>", ctx.feature)
880 }]),
881 ));
882 }
883 if !ctx.config.acceptance {
884 return Err(Box::new(refuse(
885 CommandStatus::ConfigError,
886 "ACCEPTANCE-OFF",
887 "acceptance tests are off ([acceptance] generate = false): no tests are written at review",
888 )));
889 }
890 let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
891 let Some(req) = requirements
892 .iter()
893 .find(|r| r.key.eq_ignore_ascii_case(request.req))
894 .cloned()
895 else {
896 return Err(Box::new(refuse(
897 CommandStatus::UsageError,
898 "REVIEW-NO-SCENARIOS",
899 &format!(
900 "the spec has no requirement {}; it names {}",
901 request.req,
902 if requirements.is_empty() {
903 "none with scenarios".to_owned()
904 } else {
905 requirements
906 .iter()
907 .map(|r| r.key.as_str())
908 .collect::<Vec<_>>()
909 .join(", ")
910 }
911 ),
912 )));
913 };
914 if req.scenarios.is_empty() {
915 return Err(Box::new(refuse(
916 CommandStatus::UsageError,
917 "REVIEW-NO-SCENARIOS",
918 &format!(
919 "{} has no acceptance scenarios to write tests from",
920 req.key
921 ),
922 )));
923 }
924 let (entries, _) = crate::review::oracle_entries(&ctx.root, &ctx.dir)
927 .map_err(|e| Box::new(refuse(CommandStatus::NeedsHuman, "REVIEW-UNREADABLE", &e)))?;
928 if let Some(e) = entries
929 .get(&req.key)
930 .filter(|e| e.requirement_sha256 == req.digest())
931 {
932 return Err(Box::new(refuse(
933 CommandStatus::UsageError,
934 "ORACLE-FROZEN",
935 &format!(
936 "{}'s tests were frozen with the approval by {}: the run uses them as frozen — reword {} to lapse the freeze, then write its tests again",
937 req.key, e.by, req.key
938 ),
939 )));
940 }
941 Ok(req)
942}
943
944type Outcome = Result<String, Box<CommandResult>>;
945
946fn failed(message: impl Into<String>) -> Box<CommandResult> {
947 Box::new(refuse(
948 CommandStatus::RunFailed,
949 "ORACLE-AUTHOR-FAILED",
950 &message.into(),
951 ))
952}
953
954fn usage(message: &str) -> Box<CommandResult> {
955 Box::new(refuse(CommandStatus::UsageError, "USAGE", message))
956}
957
958fn existing(ctx: &Ctx, req: &OracleRequirement) -> Result<Loaded, Box<CommandResult>> {
959 match load(&ctx.plane_dir, &ctx.feature, &req.key) {
960 Ok(Some((d, c))) if d.requirement.sha256 == req.digest() => Ok((d, c)),
961 Ok(Some(_)) => Err(usage(&format!(
962 "{}'s tests were written for an earlier wording: write them again (`speccoder review tests {} --write`)",
963 req.key, req.key
964 ))),
965 Ok(None) => Err(usage(&format!(
966 "{} has no tests yet: `speccoder review tests {} --write`",
967 req.key, req.key
968 ))),
969 Err(t) => Err(Box::new(refuse(
970 CommandStatus::NeedsHuman,
971 "ORACLE-TAMPERED",
972 &t.0,
973 ))),
974 }
975}
976
977enum Held<'a, T> {
980 Own(T),
981 Lent(&'a T),
982}
983
984impl<T> std::ops::Deref for Held<'_, T> {
985 type Target = T;
986 fn deref(&self) -> &T {
987 match self {
988 Self::Own(t) => t,
989 Self::Lent(t) => t,
990 }
991 }
992}
993
994struct Author<'a> {
995 ctx: &'a Ctx,
996 rt: Held<'a, tokio::runtime::Runtime>,
997 plane: Held<'a, substrate::Plane>,
998 bundle: Value,
999 instructions: Vec<crate::instructions::Instruction>,
1000 test_layout: String,
1002 spent: std::cell::RefCell<Option<Value>>,
1004}
1005
1006impl<'a> Author<'a> {
1007 fn open(
1008 ctx: &'a Ctx,
1009 req: &OracleRequirement,
1010 provider: Option<Arc<dyn ModelProvider>>,
1011 options: &crate::RunOptions,
1012 ) -> Result<Self, Box<CommandResult>> {
1013 let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
1014 return Err(Box::new(refuse(
1015 CommandStatus::ConfigError,
1016 "CONFIG",
1017 "the test author needs a model ([model] in speccoder.toml)",
1018 )));
1019 };
1020 let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
1021 let plane = rt
1022 .block_on(async {
1023 substrate::Plane::open_with(
1024 &ctx.plane_dir,
1025 model,
1026 provider,
1027 None,
1028 substrate::Hooks {
1029 format: Some(ctx.format),
1030 progress: options.progress.clone(),
1031 ..substrate::Hooks::default()
1032 },
1033 )
1034 })
1035 .map_err(|e| failed(e.0))?;
1036 Ok(Self::with(ctx, req, Held::Own(rt), Held::Own(plane)))
1037 }
1038
1039 fn lent(
1041 ctx: &'a Ctx,
1042 req: &OracleRequirement,
1043 rt: &'a tokio::runtime::Runtime,
1044 plane: &'a substrate::Plane,
1045 ) -> Self {
1046 Self::with(ctx, req, Held::Lent(rt), Held::Lent(plane))
1047 }
1048
1049 fn with(
1050 ctx: &'a Ctx,
1051 req: &OracleRequirement,
1052 rt: Held<'a, tokio::runtime::Runtime>,
1053 plane: Held<'a, substrate::Plane>,
1054 ) -> Self {
1055 let unit_dir = ctx.root.join(&ctx.dir);
1056 let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
1057 let bundle = json!({
1058 "feature": ctx.feature,
1059 "constitution": constitution,
1060 "plan": plan,
1061 "story": req.text,
1062 "task": {
1063 "id": format!("req-{}", req.key),
1064 "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1065 "phase": "Review",
1066 "subsection": "",
1067 "story": req.key,
1068 "parallel": false,
1069 },
1070 "dependencies": [],
1071 });
1072 let instructions =
1073 crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1074 Self {
1075 ctx,
1076 rt,
1077 plane,
1078 bundle,
1079 instructions,
1080 test_layout: crate::layout::text_at(&ctx.root),
1081 spent: std::cell::RefCell::new(None),
1082 }
1083 }
1084
1085 fn context(
1087 &self,
1088 req: &OracleRequirement,
1089 notes: &[String],
1090 ) -> speccoder_core::context::ContextManifest {
1091 let mut m = crate::context::Manifest::new();
1092 m.push(
1093 ContextKind::Story,
1094 "person",
1095 "/requirement",
1096 req.text.as_bytes(),
1097 );
1098 for s in &req.scenarios {
1099 m.push(
1100 ContextKind::Scenario,
1101 "person",
1102 &format!("/scenarios/{}", s.id),
1103 s.text.as_bytes(),
1104 );
1105 }
1106 m.push(
1107 ContextKind::Constitution,
1108 "worktree",
1109 "/bundle/constitution",
1110 self.bundle["constitution"]
1111 .as_str()
1112 .unwrap_or_default()
1113 .as_bytes(),
1114 );
1115 m.push(
1116 ContextKind::Plan,
1117 "worktree",
1118 "/bundle/plan",
1119 self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1120 );
1121 m.push(
1122 ContextKind::TestLayout,
1123 "worktree",
1124 "/test_layout",
1125 self.test_layout.as_bytes(),
1126 );
1127 for (i, n) in notes.iter().enumerate() {
1128 let kind = if n.starts_with("Rejected `") {
1129 ContextKind::Rejection
1130 } else if n.starts_with("Comment ") {
1131 ContextKind::Comment
1132 } else {
1133 ContextKind::Note
1134 };
1135 m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1136 }
1137 m.finish()
1138 }
1139
1140 #[allow(clippy::too_many_arguments)]
1142 fn call(
1143 &self,
1144 req: &OracleRequirement,
1145 files: &[String],
1146 manifest: &str,
1147 notes: &[String],
1148 refused: &[String],
1149 again: Option<(&str, &str)>,
1150 sample: u8,
1151 places: &[substrate::acceptance::ScenarioPlace],
1152 ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1153 let context = self.context(req, notes);
1154 let suite = crate::pipeline::acceptance::suite(&self.ctx.root, &req.text, &[]);
1155 let interface = crate::pipeline::acceptance::interface(&self.ctx.root, &req.text);
1156 let commands: Vec<String> = if crate::layout::via_command(&req.text) {
1157 crate::layout::commands(&self.ctx.root)
1158 .into_iter()
1159 .map(|c| c.shown)
1160 .collect()
1161 } else {
1162 Vec::new()
1163 };
1164 let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1165 bundle: self.bundle.clone(),
1166 requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1167 scenarios: &req.scenarios,
1168 files,
1169 manifest,
1170 notes,
1171 again,
1172 instructions: &self.instructions,
1173 context: &context,
1174 sample,
1175 test_layout: Some(&self.test_layout),
1176 suite: &suite,
1177 interface: &interface,
1178 commands: &commands,
1179 refused,
1180 places,
1181 });
1182 let task = format!("req-{}", req.key);
1183 let outcome = self
1184 .rt
1185 .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1186 .map_err(|e| format!("the test author failed: {}", e.0))?;
1187 let output = outcome
1188 .output
1189 .as_ref()
1190 .map(|o| o.peek().clone())
1191 .unwrap_or_default();
1192 let answer = output["answer"].clone();
1193 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1194 {
1195 let status = format!("{:?}", outcome.status);
1196 return Err(substrate::provider_refusal(&status).map_or_else(
1197 || {
1198 format!(
1199 "the test author did not answer: {}",
1200 substrate::why_not(&outcome.status)
1201 )
1202 },
1203 |r| format!("the model provider refused the request: {r}"),
1204 ));
1205 }
1206 let total =
1207 crate::pipeline::commit::add_usage(self.spent.borrow().as_ref(), output.get("usage"));
1208 *self.spent.borrow_mut() = total;
1209 Ok((answer, outcome.run_id.to_string(), context.entries))
1210 }
1211}
1212
1213fn expectations(
1219 answer: &Value,
1220 req: &OracleRequirement,
1221) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1222 let canonical = |id: &str| {
1223 req.scenarios
1224 .iter()
1225 .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1226 .map(|s| s.id.clone())
1227 .ok_or_else(|| {
1228 format!(
1229 "the test author's expectations name `{id}`, which {} does not have",
1230 req.key
1231 )
1232 })
1233 };
1234 let list = |field: &str| -> Result<Value, String> {
1235 match &answer[field] {
1236 Value::Null => Ok(Value::Array(Vec::new())),
1237 v @ Value::Array(_) => Ok(v.clone()),
1238 _ => Err(format!("the test author's `{field}` is not a list")),
1239 }
1240 };
1241 let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1242 .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1243 let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1244 .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1245 for x in &mut e {
1246 x.scenario = canonical(&x.scenario)?;
1247 }
1248 for x in &mut u {
1249 x.scenario = canonical(&x.scenario)?;
1250 }
1251 if e.is_empty() && u.is_empty() {
1252 return Err(
1253 "the test author stated no expectation for any scenario: the readings cannot be compared"
1254 .to_owned(),
1255 );
1256 }
1257 Ok((e, u))
1258}
1259
1260struct Bench {
1263 root: PathBuf,
1266 _scratch: Option<worktree::Scratch>,
1267 project: PathBuf,
1268 runner: sandbox::Runner,
1269 tmp: PathBuf,
1270}
1271
1272impl Bench {
1273 fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1274 let tmp = ctx.plane_dir.join("tmp");
1275 let scratch = worktree::scratch(&ctx.root, &tmp)
1276 .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1277 let project = project_in(&ctx.root, &scratch.path);
1278 let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1279 .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1280 if !runner.is_sandboxed() {
1283 return Err(Box::new(
1284 refuse(
1285 CommandStatus::ConfigError,
1286 "SANDBOX-UNAVAILABLE",
1287 &format!(
1288 "the red check runs model-written tests and needs the OS sandbox: {}",
1289 notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1290 ),
1291 )
1292 .with_fix(vec![
1293 "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1294 ]),
1295 ));
1296 }
1297 crate::include::copy(&scratch.path, &project, &ctx.root, &ctx.worktree.include).map_err(
1299 |e| {
1300 Box::new(refuse(
1301 CommandStatus::ConfigError,
1302 "PREPARE-FAILED",
1303 &format!("the files [worktree] include names could not be copied: {e}"),
1304 ))
1305 },
1306 )?;
1307 if ctx.worktree.prepare && crate::prepare::needed(&project) {
1308 crate::prepare::prepare(&crate::prepare::Prepare {
1309 worktree: &scratch.path,
1310 project: &project,
1311 config: &ctx.worktree,
1312 runner: &runner,
1313 state: Some(&ctx.state),
1314 })
1315 .map_err(|f| {
1316 Box::new(
1317 refuse(CommandStatus::ConfigError, "PREPARE-FAILED", &f.message)
1318 .with_fix(f.fix)
1319 .with_commands(f.commands),
1320 )
1321 })?;
1322 }
1323 Ok(Self {
1324 root: scratch.path.clone(),
1325 _scratch: Some(scratch),
1326 project,
1327 runner,
1328 tmp,
1329 })
1330 }
1331
1332 fn here(ctx: &Ctx, wt_root: &Path, runner: sandbox::Runner) -> Self {
1335 Self {
1336 root: wt_root.to_path_buf(),
1337 _scratch: None,
1338 project: ctx.root.clone(),
1339 runner,
1340 tmp: ctx.plane_dir.join("tmp"),
1341 }
1342 }
1343
1344 fn files(&self) -> (Vec<String>, String) {
1345 let files: Vec<String> = worktree::checkpoint(&self.root, &self.tmp)
1346 .and_then(|c| worktree::files(&self.root, &c))
1347 .unwrap_or_default()
1348 .into_iter()
1349 .take(400)
1350 .collect();
1351 let manifest = crate::pipeline::MANIFESTS
1352 .iter()
1353 .find_map(|m| {
1354 std::fs::read_to_string(self.project.join(m))
1355 .ok()
1356 .map(|t| format!("{m}:\n{t}"))
1357 })
1358 .map(|t| t.chars().take(8192).collect::<String>())
1359 .unwrap_or_default();
1360 (files, manifest)
1361 }
1362
1363 fn red(
1369 &self,
1370 tests: &[(String, String, Vec<String>)],
1371 ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1372 let mut out = Vec::new();
1373 for (path, content, argv) in tests {
1374 let full = self.project.join(path);
1375 let _ = std::fs::remove_file(&full);
1376 let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1377 let (bare, summary) = run();
1378 if classify_red(&bare, summary.as_ref()).is_red() {
1379 out.push(Err("red_without_it"));
1380 continue;
1381 }
1382 if let Some(parent) = full.parent() {
1383 std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1384 }
1385 std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1386 let (first, summary) = run();
1387 let class = classify_red(&first, summary.as_ref());
1388 let class = if !class.is_red() {
1389 class
1390 } else {
1391 let (second, summary) = run();
1392 if classify_red(&second, summary.as_ref()).is_red() {
1393 class
1394 } else {
1395 RedClass::Flaky
1396 }
1397 };
1398 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1399 out.push(Ok(class));
1400 }
1401 Ok(out)
1402 }
1403}
1404
1405fn keep(
1407 ctx: &Ctx,
1408 bench: &Bench,
1409 answer: &Value,
1410 req: &OracleRequirement,
1411 taken: &[(String, Vec<String>)],
1412) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1413 let (valid, refused) =
1414 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios, None);
1415 let mut discarded: Vec<OracleDiscarded> = refused
1416 .into_iter()
1417 .map(|r| OracleDiscarded {
1418 path: r.path,
1419 why: r.why.to_owned(),
1420 })
1421 .collect();
1422 let set_argv: Vec<String> = crate::pipeline::acceptance::argv_of(&answer["argv"]);
1423 let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1424 let commands = crate::layout::commands(&bench.project);
1426 let via_command = !commands.is_empty() && crate::layout::via_command(&req.text);
1427 let mut kept = Vec::new();
1428 for f in valid {
1429 let mut discard = |why: &str| {
1430 discarded.push(OracleDiscarded {
1431 path: f.path.clone(),
1432 why: why.to_owned(),
1433 });
1434 };
1435 if f.cited.is_empty() {
1436 discard("cites_no_scenario");
1437 continue;
1438 }
1439 if via_command && !commands.iter().any(|c| c.run_by(&f.content)) {
1440 discard("no_command");
1441 continue;
1442 }
1443 if taken.iter().any(|(p, _)| *p == f.path)
1444 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1445 {
1446 discard("exists");
1447 continue;
1448 }
1449 let argv = if f.argv.is_empty() {
1450 set_argv.clone()
1451 } else {
1452 f.argv.clone()
1453 };
1454 if taken.iter().any(|(_, a)| *a == argv)
1457 || kept
1458 .iter()
1459 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1460 {
1461 discard("command_shared");
1462 continue;
1463 }
1464 if argv.is_empty()
1465 || !crate::pipeline::test_command_allowed(
1466 &ctx.policy,
1467 Some(&layout),
1468 &argv,
1469 &[f.path.as_str()],
1470 )
1471 {
1472 discard("command_not_allowed");
1473 continue;
1474 }
1475 if kept.len() + taken.len() >= LIMIT {
1476 discard("limit");
1477 continue;
1478 }
1479 kept.push((f.path, f.content, f.cited, argv));
1480 }
1481 (kept, discarded)
1482}
1483
1484fn write(
1487 ctx: &Ctx,
1488 req: &OracleRequirement,
1489 provider: Option<Arc<dyn ModelProvider>>,
1490 options: &crate::RunOptions,
1491) -> Outcome {
1492 let samples = ctx.config.oracle_samples.clamp(1, 2);
1493 let author = Author::open(ctx, req, provider, options)?;
1494 let bench = Bench::open(ctx)?;
1495 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1498 Box::new(
1499 refuse(
1500 CommandStatus::NeedsHuman,
1501 "ORACLE-TAMPERED",
1502 &format!(
1503 "{}: writing anew would lose the rejections and notes it holds",
1504 t.0
1505 ),
1506 )
1507 .with_fix(vec![format!(
1508 "→ restore it, or remove {} to start {}'s tests over",
1509 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1510 req.key
1511 )]),
1512 )
1513 })?;
1514 let (rejected, mut notes) = earlier
1515 .as_ref()
1516 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1517 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1518 .unwrap_or_default();
1519 notes.dedup();
1520 let told: Vec<String> = rejected
1521 .iter()
1522 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1523 .chain(notes.iter().cloned())
1524 .collect();
1525 let (draft, contents) = draft(
1526 ctx,
1527 req,
1528 &author,
1529 &bench,
1530 samples,
1531 &told,
1532 &[],
1533 rejected,
1534 notes,
1535 true,
1536 options,
1537 )?;
1538 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1539 let red = draft.red().count();
1540 let open = draft.open_ambiguities().count();
1541 Ok(format!(
1542 "wrote {} test(s) for {}: {red} red{}{}",
1543 draft.tests.len(),
1544 req.key,
1545 if draft.held_out.state == "kept" {
1546 format!(", {} held out", draft.held_out.count)
1547 } else {
1548 String::new()
1549 },
1550 if open > 0 {
1551 format!(
1552 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1553 req.key, req.key
1554 )
1555 } else {
1556 String::new()
1557 }
1558 ))
1559}
1560
1561fn places(
1565 project: &Path,
1566 root: &Path,
1567 req: &OracleRequirement,
1568) -> Vec<substrate::acceptance::ScenarioPlace> {
1569 let layout = crate::layout::detect(project, &crate::verify::check_argvs(root));
1570 req.scenarios
1571 .iter()
1572 .filter_map(|s| {
1573 let name = s.id.replace('-', "_");
1574 let path = (1..10)
1575 .filter_map(|n| {
1576 let name = if n == 1 {
1577 name.clone()
1578 } else {
1579 format!("{name}_{n}")
1580 };
1581 layout.acceptance_file(&name, None)
1582 })
1583 .find(|p| !project.join(p).exists())?;
1584 Some(substrate::acceptance::ScenarioPlace {
1585 scenario: s.id.clone(),
1586 command: layout.command_for(&path).unwrap_or_default(),
1587 path,
1588 })
1589 })
1590 .collect()
1591}
1592
1593#[allow(clippy::too_many_arguments)]
1596fn draft(
1597 ctx: &Ctx,
1598 req: &OracleRequirement,
1599 author: &Author<'_>,
1600 bench: &Bench,
1601 samples: u8,
1602 told: &[String],
1603 refused: &[String],
1604 rejected: Vec<OracleRejected>,
1605 notes: Vec<String>,
1606 with_held_out: bool,
1607 options: &crate::RunOptions,
1608) -> Result<Loaded, Box<CommandResult>> {
1609 let (files, manifest) = bench.files();
1610 let places = places(&bench.project, &ctx.root, req);
1611 let of = u32::from(samples);
1612 stage(options, &req.key, "writing", 1, of);
1613 let (first, run1, mut context) = author
1614 .call(req, &files, &manifest, told, refused, None, 1, &places)
1615 .map_err(failed)?;
1616 let read = |answer: &Value| {
1619 let got = expectations(answer, req);
1620 if samples >= 2 {
1621 got.map_err(failed)
1622 } else {
1623 Ok(got.unwrap_or_default())
1624 }
1625 };
1626 let (e1, u1) = read(&first)?;
1627 let mut runs = vec![run1];
1628 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1629 if samples >= 2 {
1630 stage(options, &req.key, "writing", 2, of);
1631 let (second, run2, more) = author
1632 .call(req, &files, &manifest, told, refused, None, 2, &places)
1633 .map_err(failed)?;
1634 (e2, u2) = read(&second)?;
1635 runs.push(run2);
1636 for c in more {
1637 if !context.contains(&c) {
1638 context.push(c);
1639 }
1640 }
1641 stage(options, &req.key, "comparing", 1, 1);
1642 }
1643 let ambiguities = if samples >= 2 {
1644 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1645 } else {
1646 Vec::new()
1647 };
1648 let (kept, mut discarded) = keep(ctx, bench, &first, req, &[]);
1649 stage(options, &req.key, "checking_red", 1, 1);
1650 let plan: Vec<(String, String, Vec<String>)> = kept
1651 .iter()
1652 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1653 .collect();
1654 let classes = bench.red(&plan).map_err(failed)?;
1655 let mut tests: Vec<DraftTest> = Vec::new();
1656 let mut contents: Vec<(String, String)> = Vec::new();
1657 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1658 match class {
1659 Ok(red_class) => {
1660 tests.push(DraftTest {
1661 path: path.clone(),
1662 sha256: digest_of(&content),
1663 scenarios: cited,
1664 argv,
1665 red_class,
1666 authored_by: AuthoredBy::Model,
1667 edited_by: None,
1668 });
1669 contents.push((path, content));
1670 }
1671 Err(why) => discarded.push(OracleDiscarded {
1672 path,
1673 why: why.to_owned(),
1674 }),
1675 }
1676 }
1677 let (held_out, sealed) = if with_held_out {
1679 held_out(ctx, author, bench, req, &tests, options)
1680 } else {
1681 (
1682 HeldOutSummary {
1683 state: "off".to_owned(),
1684 count: 0,
1685 manifest_sha256: None,
1686 reason: None,
1687 discarded: Vec::new(),
1688 },
1689 None,
1690 )
1691 };
1692 let draft = Draft {
1693 requirement: DraftRequirement {
1694 key: req.key.clone(),
1695 title: req.title.clone(),
1696 text: req.text.clone(),
1697 sha256: req.digest(),
1698 },
1699 samples,
1700 tests,
1701 discarded,
1702 rejected,
1703 expectations: Samples {
1704 sample1: e1,
1705 sample2: e2,
1706 },
1707 untestable: Samples {
1708 sample1: u1,
1709 sample2: u2,
1710 },
1711 ambiguities,
1712 held_out,
1713 sealed,
1714 notes,
1715 runs,
1716 context,
1717 };
1718 Ok((draft, contents))
1719}
1720
1721fn held_out(
1725 ctx: &Ctx,
1726 author: &Author<'_>,
1727 bench: &Bench,
1728 req: &OracleRequirement,
1729 tests: &[DraftTest],
1730 options: &crate::RunOptions,
1731) -> (HeldOutSummary, Option<SealedRef>) {
1732 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1733 state: state.to_owned(),
1734 count: 0,
1735 manifest_sha256: None,
1736 reason: reason.map(str::to_owned),
1737 discarded: Vec::new(),
1738 };
1739 if !ctx.config.held_out {
1740 return (summary("off", None), None);
1741 }
1742 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1743 if red.is_empty() {
1744 return (summary("absent", Some("not_red")), None);
1745 }
1746 stage(options, &req.key, "held_out", 1, 1);
1747 let visible = crate::pipeline::PreparedAcceptance {
1748 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1749 argv: red.first().map(|t| t.argv.clone()),
1750 more_argv: Vec::new(),
1751 red: true,
1752 red_class: Some(RedClass::Assertion),
1753 held_out: None,
1754 generation_failed: None,
1755 skip_cause: None,
1756 };
1757 let task = format!("req-{}", req.key);
1758 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1759 let (report, sha) = crate::pipeline::prepare_held_out(
1760 &crate::pipeline::AcceptanceSetup {
1761 rt: &author.rt,
1762 plane: &author.plane,
1763 project: &bench.project,
1764 worktree: &bench.root,
1765 tmp: &bench.tmp,
1766 policy: &ctx.policy,
1767 feature: &ctx.feature,
1768 task: &task,
1769 bundle: author.bundle.clone(),
1770 scenarios: &req.scenarios,
1771 instructions: &author.instructions,
1772 runner: &bench.runner,
1773 layout: None,
1774 told: &[],
1775 related: &[],
1776 oracle: true,
1777 },
1778 &visible,
1779 &req.scenarios,
1780 &store,
1781 false,
1782 );
1783 let total =
1785 crate::pipeline::commit::add_usage(author.spent.borrow().as_ref(), report.get("usage"));
1786 *author.spent.borrow_mut() = total;
1787 match sha {
1788 Some(sha) => {
1789 let count = report["kept"]
1790 .as_u64()
1791 .and_then(|n| usize::try_from(n).ok())
1792 .unwrap_or(0);
1793 (
1794 HeldOutSummary {
1795 state: "kept".to_owned(),
1796 count,
1797 manifest_sha256: Some(format!("sha256:{sha}")),
1798 reason: None,
1799 discarded: Vec::new(),
1800 },
1801 Some(SealedRef {
1802 key: req.key.clone(),
1803 manifest_sha256: sha,
1804 count,
1805 }),
1806 )
1807 }
1808 None => (
1809 HeldOutSummary {
1810 discarded: report["discarded"]
1811 .as_array()
1812 .into_iter()
1813 .flatten()
1814 .filter_map(|d| d["reason"].as_str().map(str::to_owned))
1815 .collect(),
1816 ..summary(
1817 "absent",
1818 Some(report["reason"].as_str().unwrap_or("none_kept")),
1819 )
1820 },
1821 None,
1822 ),
1823 }
1824}
1825
1826fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1827 let Some(path) = r.path else {
1828 return Err(usage("name the test to reject (--reject PATH)"));
1829 };
1830 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1831 return Err(usage(
1832 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1833 ));
1834 };
1835 let (mut d, contents) = existing(ctx, req)?;
1836 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1837 return Err(usage(&format!("{} has no test {path}", req.key)));
1838 };
1839 d.tests.remove(at);
1840 d.rejected.push(OracleRejected {
1841 path: path.to_owned(),
1842 reason: reason.to_owned(),
1843 by: by.to_owned(),
1844 at: now(),
1845 });
1846 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1847 Ok(format!(
1848 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1849 req.key, req.key
1850 ))
1851}
1852
1853fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1854 let Some(path) = r.path else {
1855 return Err(usage("name the test to edit (--edit PATH)"));
1856 };
1857 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1858 return Err(usage("an edit needs the test's new content (--from FILE)"));
1859 };
1860 let (mut d, mut contents) = existing(ctx, req)?;
1861 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1862 return Err(usage(&format!("{} has no test {path}", req.key)));
1863 };
1864 let bench = Bench::open(ctx)?;
1865 let plan = [(
1866 path.to_owned(),
1867 content.to_owned(),
1868 d.tests[at].argv.clone(),
1869 )];
1870 let checked = bench
1871 .red(&plan)
1872 .map_err(failed)?
1873 .pop()
1874 .unwrap_or(Ok(RedClass::Unclassified));
1875 let class = checked.unwrap_or(RedClass::Unclassified);
1877 let t = &mut d.tests[at];
1878 t.sha256 = digest_of(content);
1879 t.red_class = class;
1880 t.authored_by = AuthoredBy::Person;
1881 t.edited_by = Some(by.to_owned());
1882 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1883 c.1 = content.to_owned();
1884 }
1885 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1886 Ok(format!(
1887 "edited {path} of {}: {}",
1888 req.key,
1889 if class.is_red() {
1890 format!("red ({})", crate::pipeline::class_word(class))
1891 } else if checked.is_err() {
1892 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1893 } else {
1894 format!(
1895 "not red ({}) — no evidence, it cannot be frozen",
1896 crate::pipeline::class_word(class)
1897 )
1898 }
1899 ))
1900}
1901
1902#[allow(clippy::too_many_arguments)]
1905fn rewrite(
1906 ctx: &Ctx,
1907 req: &OracleRequirement,
1908 mut d: Draft,
1909 mut contents: Vec<(String, String)>,
1910 replaced: &[String],
1911 note: String,
1912 again: Option<(&str, &str)>,
1913 only: Option<&str>,
1914 provider: Option<Arc<dyn ModelProvider>>,
1915 options: &crate::RunOptions,
1916) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1917 let author = Author::open(ctx, req, provider, options)?;
1918 let bench = Bench::open(ctx)?;
1919 let (files, manifest) = bench.files();
1920 d.notes.push(note);
1921 let told = d.all_notes();
1922 stage(options, &req.key, "writing", 1, 1);
1923 let (answer, run, context) = author
1924 .call(req, &files, &manifest, &told, &[], again, 1, &[])
1925 .map_err(failed)?;
1926 d.runs.push(run);
1927 for c in context {
1928 if !d.context.contains(&c) {
1929 d.context.push(c);
1930 }
1931 }
1932 let keep_paths: Vec<(String, Vec<String>)> = d
1933 .tests
1934 .iter()
1935 .filter(|t| !replaced.contains(&t.path))
1936 .map(|t| (t.path.clone(), t.argv.clone()))
1937 .collect();
1938 let mut answer = answer;
1940 if let Some(files) = answer["files"].as_array_mut() {
1941 files.retain(|f| {
1942 only.is_none_or(|s| {
1943 f["scenarios"]
1944 .as_array()
1945 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1946 })
1947 });
1948 }
1949 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1950 if new.is_empty() {
1951 return Err(failed(format!(
1952 "the test author wrote no usable test{}",
1953 discarded
1954 .first()
1955 .map(|x| format!(" ({} {})", x.path, x.why))
1956 .unwrap_or_default()
1957 )));
1958 }
1959 d.tests.retain(|t| !replaced.contains(&t.path));
1960 contents.retain(|(p, _)| !replaced.contains(p));
1961 d.discarded.extend(discarded);
1962 stage(options, &req.key, "checking_red", 1, 1);
1963 let plan: Vec<(String, String, Vec<String>)> = new
1965 .iter()
1966 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1967 .collect();
1968 let classes = bench.red(&plan).map_err(failed)?;
1969 let mut written = Vec::new();
1970 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1971 let red_class = match class {
1972 Ok(c) => c,
1973 Err(why) => {
1974 d.discarded.push(OracleDiscarded {
1975 path,
1976 why: why.to_owned(),
1977 });
1978 continue;
1979 }
1980 };
1981 d.tests.push(DraftTest {
1982 sha256: digest_of(&content),
1983 scenarios: cited,
1984 argv,
1985 red_class,
1986 authored_by: AuthoredBy::Model,
1987 edited_by: None,
1988 path: path.clone(),
1989 });
1990 contents.push((path.clone(), content));
1991 written.push(path);
1992 }
1993 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1994 Ok((d, written))
1995}
1996
1997fn again(
1998 ctx: &Ctx,
1999 req: &OracleRequirement,
2000 r: &Request<'_>,
2001 _by: &str,
2002 provider: Option<Arc<dyn ModelProvider>>,
2003 options: &crate::RunOptions,
2004) -> Outcome {
2005 let Some(path) = r.path else {
2006 return Err(usage("name the test to write again (--again PATH)"));
2007 };
2008 let comment = r
2009 .text
2010 .map(str::trim)
2011 .filter(|t| !t.is_empty())
2012 .unwrap_or("write it again");
2013 let (d, contents) = existing(ctx, req)?;
2014 let Some(content) = contents
2015 .iter()
2016 .find(|(p, _)| p == path)
2017 .map(|(_, c)| c.clone())
2018 else {
2019 return Err(usage(&format!("{} has no test {path}", req.key)));
2020 };
2021 let (d, written) = rewrite(
2022 ctx,
2023 req,
2024 d,
2025 contents,
2026 &[path.to_owned()],
2027 format!("Comment on `{path}`: {comment}"),
2028 Some((path, &content)),
2029 None,
2030 provider,
2031 options,
2032 )?;
2033 let red = d
2034 .tests
2035 .iter()
2036 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
2037 .count();
2038 Ok(format!(
2039 "wrote {path} of {} again: {}",
2040 req.key,
2041 if written.is_empty() {
2042 "nothing kept — its command is red without it, so it decides nothing".to_owned()
2043 } else if red == written.len() {
2044 "red".to_owned()
2045 } else {
2046 "not red — no evidence, it cannot be frozen".to_owned()
2047 }
2048 ))
2049}
2050
2051fn reading(
2052 ctx: &Ctx,
2053 req: &OracleRequirement,
2054 r: &Request<'_>,
2055 by: &str,
2056 provider: Option<Arc<dyn ModelProvider>>,
2057 options: &crate::RunOptions,
2058) -> Outcome {
2059 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
2060 return Err(usage(
2061 "name the reading and the scenario (--reading A|B US1-2)",
2062 ));
2063 };
2064 let (mut d, contents) = existing(ctx, req)?;
2065 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
2066 return Err(usage(&format!(
2067 "{} has no ambiguity on {scenario}",
2068 req.key
2069 )));
2070 };
2071 item.resolution = Some(Resolution {
2072 reading: choice,
2073 by: by.to_owned(),
2074 at: now(),
2075 });
2076 let meant = if choice == Reading::A {
2077 item.a.clone()
2078 } else {
2079 item.b.clone()
2080 };
2081 if choice == Reading::A {
2082 d.notes.push(format!(
2083 "For {scenario} the person means reading A: given {}, expect {}",
2084 meant.given, meant.expect
2085 ));
2086 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
2087 return Ok(format!(
2088 "{scenario} of {}: reading A is meant — the tests stay",
2089 req.key
2090 ));
2091 }
2092 let replaced: Vec<String> = d
2093 .tests
2094 .iter()
2095 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
2096 .map(|t| t.path.clone())
2097 .collect();
2098 let (_, written) = rewrite(
2099 ctx,
2100 req,
2101 d,
2102 contents,
2103 &replaced,
2104 format!(
2105 "For {scenario} the person means reading B: given {}, expect {}",
2106 meant.given, meant.expect
2107 ),
2108 None,
2109 Some(scenario),
2110 provider,
2111 options,
2112 )?;
2113 Ok(format!(
2114 "{scenario} of {}: reading B is meant — wrote {} anew",
2115 req.key,
2116 written.join(", ")
2117 ))
2118}
2119
2120#[derive(Debug, Clone)]
2123pub struct Freezable {
2124 pub req: OracleRequirement,
2126 pub draft: Draft,
2128 pub contents: Vec<(String, String)>,
2130 pub manifest: String,
2132}
2133
2134pub fn freezable(
2137 project_state: &Path,
2138 unit: &str,
2139 requirements: &[OracleRequirement],
2140) -> Result<Vec<Freezable>, Tampered> {
2141 let mut out = Vec::new();
2142 for r in requirements {
2143 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
2144 continue;
2145 };
2146 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
2147 continue;
2148 }
2149 let Some(manifest) = draft.manifest() else {
2150 continue;
2151 };
2152 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
2153 out.push(Freezable {
2154 req: r.clone(),
2155 contents: contents
2156 .into_iter()
2157 .filter(|(p, _)| red.contains(p))
2158 .collect(),
2159 draft,
2160 manifest,
2161 });
2162 }
2163 Ok(out)
2164}
2165
2166pub fn freeze(
2170 project_state: &Path,
2171 unit: &str,
2172 f: &Freezable,
2173 approval_digest: &str,
2174 decided: Decided,
2175 producer: Producer,
2176) -> Result<PreFrozenAcceptance, String> {
2177 let red: Vec<&DraftTest> = f.draft.red().collect();
2178 freeze_tests(
2179 project_state,
2180 unit,
2181 f,
2182 &red,
2183 approval_digest,
2184 decided,
2185 producer,
2186 )
2187}
2188
2189fn freeze_tests(
2191 project_state: &Path,
2192 unit: &str,
2193 f: &Freezable,
2194 red: &[&DraftTest],
2195 approval_digest: &str,
2196 decided: Decided,
2197 producer: Producer,
2198) -> Result<PreFrozenAcceptance, String> {
2199 let sealed = match &f.draft.sealed {
2200 Some(s) => {
2201 crate::held_out::open_sealed(project_state, unit, s)
2202 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
2203 Some(s.clone())
2204 }
2205 None => None,
2206 };
2207 let record = PreFrozenAcceptance {
2208 unit: unit.to_owned(),
2209 requirement: f.req.key.clone(),
2210 wording_digest: f.draft.requirement.sha256.clone(),
2211 approval_digest: approval_digest.to_owned(),
2212 visible: red
2213 .iter()
2214 .map(|t| VisibleTest {
2215 path: t.path.clone(),
2216 sha256: t.sha256.clone(),
2217 cites: t.scenarios.clone(),
2218 })
2219 .collect(),
2220 held_out_sealed: sealed,
2221 producer,
2222 decided,
2223 };
2224 crate::pre_frozen::store(project_state, &record, &f.contents)?;
2225 let frozen = Frozen {
2226 requirement_sha256: f.draft.requirement.sha256.clone(),
2227 wording: format!(
2228 "{}\n{}",
2229 f.draft.requirement.title, f.draft.requirement.text
2230 ),
2231 manifest_sha256: f.manifest.clone(),
2232 tests: red
2233 .iter()
2234 .map(|t| FrozenTest {
2235 path: t.path.clone(),
2236 sha256: t.sha256.clone(),
2237 argv: t.argv.clone(),
2238 scenarios: t.scenarios.clone(),
2239 })
2240 .collect(),
2241 };
2242 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2243 write_atomic(
2244 &dir(project_state, unit, &f.req.key).join("frozen.json"),
2245 &text,
2246 )?;
2247 Ok(record)
2248}
2249
2250fn discard_words(why: &str) -> &'static str {
2258 match why {
2259 "cites_no_scenario" => "cites no scenario of the list",
2260 "no_command" => {
2261 "never runs the project's command — the story's interface is the command, not a module behind it"
2262 }
2263 "command_shared" => {
2264 "shares its command with another test — each test needs the command that runs only it"
2265 }
2266 "command_not_allowed" => "gives a command the policy does not allow",
2267 "limit" => "is beyond the number of tests one answer may hold",
2268 "red_without_it" => "fails without its own file — its command runs other, failing tests",
2269 _ => crate::pipeline::acceptance::refusal_words(why),
2270 }
2271}
2272
2273fn not_kept(t: &DraftTest, many: bool) -> Option<String> {
2274 if many && t.scenarios.len() > 1 {
2275 return Some(format!(
2276 "it tests {} together — one file per scenario, so each turns green on its own",
2277 t.scenarios.join(", ")
2278 ));
2279 }
2280 (!usable(t)).then(|| {
2281 format!(
2282 "it did not fail on an assertion ({})",
2283 crate::pipeline::class_word(t.red_class)
2284 )
2285 })
2286}
2287
2288fn usable(t: &DraftTest) -> bool {
2289 match t.red_class {
2290 RedClass::Assertion | RedClass::Passed => true,
2291 RedClass::Absent => [".rs", ".go", ".java", ".kt", ".cs", ".swift"]
2292 .iter()
2293 .any(|e| t.path.ends_with(e)),
2294 _ => false,
2295 }
2296}
2297
2298#[derive(Debug)]
2302pub(crate) struct RunOracle {
2303 pub tests: Vec<FrozenTest>,
2306 pub contents: Vec<(String, String)>,
2308 pub contracts: Vec<Expectation>,
2310 pub untestable: Vec<Untestable>,
2312 pub usage: Option<Value>,
2314}
2315
2316#[allow(clippy::too_many_arguments)]
2323pub(crate) fn write_for_run(
2324 project: &Path,
2325 wt_root: &Path,
2326 feature: &str,
2327 state: &Path,
2328 story: &str,
2329 runner: sandbox::Runner,
2330 (rt, plane): (&tokio::runtime::Runtime, &substrate::Plane),
2331 (only, told): (&[String], &[String]),
2332 run: &str,
2333 options: &crate::RunOptions,
2334) -> Result<RunOracle, Box<CommandResult>> {
2335 let ctx = open(project, Some(feature), state)?;
2336 let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
2337 let whole = (story == ctx.feature && !requirements.iter().any(|r| r.key == story)).then(|| {
2339 OracleRequirement {
2340 key: story.to_owned(),
2341 title: ctx.feature.clone(),
2342 text: requirements
2343 .iter()
2344 .map(|r| format!("{}: {}\n{}", r.key, r.title, r.text))
2345 .collect::<Vec<_>>()
2346 .join("\n\n"),
2347 scenarios: requirements
2348 .iter()
2349 .flat_map(|r| r.scenarios.clone())
2350 .collect(),
2351 }
2352 });
2353 let Some(mut req) = whole.or_else(|| requirements.into_iter().find(|r| r.key == story)) else {
2354 return Err(Box::new(refuse(
2355 CommandStatus::NeedsHuman,
2356 "ORACLE-UNWRITTEN",
2357 &format!("{story} has no scenarios to write tests for"),
2358 )));
2359 };
2360 if !only.is_empty() {
2362 req.scenarios.retain(|s| only.contains(&s.id));
2363 }
2364 let _lock = UnitLock::take(&ctx.plane_dir, &ctx.feature)
2365 .map_err(|e| Box::new(refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e)))?;
2366 let author = Author::lent(&ctx, &req, rt, plane);
2367 let many = req.scenarios.len() > 1;
2368 let bench = Bench::here(&ctx, wt_root, runner);
2369 let kept = |d: &Draft| -> Vec<String> {
2370 let mut covered: Vec<String> = d
2371 .tests
2372 .iter()
2373 .filter(|t| not_kept(t, many).is_none())
2374 .flat_map(|t| t.scenarios.iter().cloned())
2375 .collect();
2376 covered.sort();
2377 covered.dedup();
2378 covered
2379 };
2380 let pass = |refused: &[String]| {
2383 draft(
2384 &ctx,
2385 &req,
2386 &author,
2387 &bench,
2388 1,
2389 told,
2390 refused,
2391 Vec::new(),
2392 told.to_vec(),
2393 false,
2394 options,
2395 )
2396 };
2397 let (mut draft, mut contents) = pass(&[])?;
2398 let untestable: Vec<String> = draft
2402 .untestable
2403 .sample1
2404 .iter()
2405 .map(|u| u.scenario.clone())
2406 .collect();
2407 let broke =
2408 !draft.discarded.is_empty() || draft.tests.iter().any(|t| not_kept(t, many).is_some());
2409 let left: Vec<String> = req
2410 .scenarios
2411 .iter()
2412 .map(|s| s.id.clone())
2413 .filter(|id| !kept(&draft).contains(id) && !untestable.contains(id))
2414 .collect();
2415 let opted_out = draft.tests.is_empty() && draft.discarded.is_empty() && !untestable.is_empty();
2416 if (broke && (!left.is_empty() || kept(&draft).is_empty())) || opted_out {
2417 let again: Vec<String> = if left.is_empty() {
2418 req.scenarios.iter().map(|s| s.id.clone()).collect()
2419 } else {
2420 left
2421 };
2422 let refused: Vec<String> = draft
2423 .discarded
2424 .iter()
2425 .map(|d| format!("Not kept `{}`: it {}", d.path, discard_words(&d.why)))
2426 .chain(draft.tests.iter().filter_map(|t| {
2427 not_kept(t, many).map(|why| format!("Not kept `{}`: {why}", t.path))
2428 }))
2429 .chain(opted_out.then(|| {
2430 format!(
2431 "Called untestable: {} — the story's code not existing yet never makes a scenario untestable; test it through the story's interface",
2432 untestable.join(", ")
2433 )
2434 }))
2435 .chain(std::iter::once(format!(
2436 "Write tests again for {} — each must build and be collected without the code it tests existing yet: exercise the story through its interface, import the code under test inside each test",
2437 again.join(", ")
2438 )))
2439 .collect();
2440 let (second, more) = pass(&refused)?;
2441 if kept(&second).len() > kept(&draft).len() {
2442 (draft, contents) = (second, more);
2443 }
2444 }
2445 if only.is_empty() {
2447 let keep: Vec<DraftTest> = draft
2448 .tests
2449 .iter()
2450 .filter(|t| not_kept(t, many).is_none())
2451 .cloned()
2452 .collect();
2453 if !keep.is_empty() {
2454 (draft.held_out, draft.sealed) = held_out(&ctx, &author, &bench, &req, &keep, options);
2455 }
2456 }
2457 if only.is_empty() {
2459 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
2460 }
2461 let keep: Vec<&DraftTest> = draft
2462 .tests
2463 .iter()
2464 .filter(|t| not_kept(t, many).is_none())
2465 .collect();
2466 if keep.is_empty() && draft.tests.is_empty() && draft.discarded.is_empty() {
2469 return Ok(RunOracle {
2470 tests: Vec::new(),
2471 contents: Vec::new(),
2472 contracts: draft.expectations.sample1.clone(),
2473 untestable: draft.untestable.sample1.clone(),
2474 usage: author.spent.borrow().clone(),
2475 });
2476 }
2477 if keep.is_empty() {
2478 let why: Vec<String> = draft
2479 .discarded
2480 .iter()
2481 .map(|d| format!("`{}` {}", d.path, discard_words(&d.why)))
2482 .chain(
2483 draft
2484 .tests
2485 .iter()
2486 .filter_map(|t| not_kept(t, many).map(|why| format!("`{}`: {why}", t.path))),
2487 )
2488 .collect();
2489 return Err(Box::new(
2490 refuse(
2491 CommandStatus::NeedsHuman,
2492 "ORACLE-UNWRITTEN",
2493 &format!(
2494 "no test written for {story} could be used, asked twice: {}",
2495 why.join("; ")
2496 ),
2497 )
2498 .with_fix(vec![format!("speccoder review tests {story} --write")]),
2499 ));
2500 }
2501 let manifest = manifest_digest(
2502 keep.iter()
2503 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2504 );
2505 let paths: Vec<&str> = keep.iter().map(|t| t.path.as_str()).collect();
2506 let kept_contents: Vec<(String, String)> = contents
2507 .iter()
2508 .filter(|(p, _)| paths.contains(&p.as_str()))
2509 .cloned()
2510 .collect();
2511 let f = Freezable {
2512 req: req.clone(),
2513 draft: draft.clone(),
2514 contents: kept_contents.clone(),
2515 manifest,
2516 };
2517 if only.is_empty() {
2520 freeze_tests(
2521 &ctx.plane_dir,
2522 &ctx.feature,
2523 &f,
2524 &keep,
2525 &format!(
2526 "run:{run}{}",
2527 if only.is_empty() { "" } else { ":amendment" }
2528 ),
2529 Decided {
2530 by: "speccoder run".to_owned(),
2531 via: Via::Terminal,
2532 at: now(),
2533 },
2534 Producer::Run,
2535 )
2536 .map_err(failed)?;
2537 }
2538 Ok(RunOracle {
2539 tests: keep
2540 .iter()
2541 .map(|t| FrozenTest {
2542 path: t.path.clone(),
2543 sha256: t.sha256.clone(),
2544 argv: t.argv.clone(),
2545 scenarios: t.scenarios.clone(),
2546 })
2547 .collect(),
2548 contents: kept_contents,
2549 contracts: draft.expectations.sample1.clone(),
2550 untestable: draft.untestable.sample1.clone(),
2551 usage: author.spent.borrow().clone(),
2552 })
2553}
2554
2555pub(crate) fn refreeze(
2562 project_state: &Path,
2563 unit: &str,
2564 key: &str,
2565 tests: Vec<FrozenTest>,
2566) -> Result<(), String> {
2567 let mut frozen = load_frozen(project_state, unit, key)
2568 .ok_or_else(|| format!("{key} has no frozen oracle here"))?;
2569 frozen.manifest_sha256 = manifest_digest(
2570 tests
2571 .iter()
2572 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2573 );
2574 frozen.tests = tests;
2575 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2576 write_atomic(&dir(project_state, unit, key).join("frozen.json"), &text)
2577}
2578
2579#[derive(Debug)]
2581pub(crate) enum TaskReuse {
2582 Ready {
2584 record: Box<PreFrozenAcceptance>,
2586 files: Vec<(String, String)>,
2588 tests: Vec<crate::pipeline::ReusedTest>,
2590 manifest: String,
2592 },
2593 Reused {
2595 req: String,
2597 },
2598 Elsewhere {
2600 req: String,
2602 },
2603 Lapsed {
2605 req: String,
2607 frozen: String,
2609 current: String,
2611 },
2612 Tampered(String),
2614 Unreadable(String),
2616}
2617
2618pub(crate) fn for_task(
2623 project_state: &Path,
2624 project: &Path,
2625 wt_root: &Path,
2626 format: &dyn Adapter,
2627 snapshot: &ProjectSnapshot,
2628 scenarios: &[speccoder_core::spec::ScenarioRef],
2629) -> Option<TaskReuse> {
2630 let f = snapshot.feature.as_ref()?;
2631 let unit = f.feature.name.as_str();
2632 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2633 Ok(x) => x,
2634 Err(e) => return Some(TaskReuse::Unreadable(e)),
2636 };
2637 if entries.is_empty() {
2638 return None;
2639 }
2640 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2641 let requirements = format.oracle_requirements(snapshot);
2642 let (req, entry) = requirements.iter().find_map(|r| {
2643 let e = entries.get(&r.key)?;
2644 r.scenarios
2645 .iter()
2646 .any(|s| ids.contains(&s.id.as_str()))
2647 .then_some((r, e))
2648 })?;
2649 let digest = req.digest();
2650 if entry.requirement_sha256 != digest {
2651 return Some(TaskReuse::Lapsed {
2652 req: req.key.clone(),
2653 frozen: entry.requirement_sha256.clone(),
2654 current: digest,
2655 });
2656 }
2657 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2658 Ok(Some(x)) => x,
2659 Ok(None) => {
2660 return Some(TaskReuse::Elsewhere {
2661 req: req.key.clone(),
2662 });
2663 }
2664 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2665 };
2666 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2670 let reused = crate::git::Git::at(wt_root)
2671 .run(&[
2672 "log",
2673 "--format=%H",
2674 "--fixed-strings",
2675 &format!("--grep={needle}"),
2676 "HEAD",
2677 ])
2678 .map(|out| !out.is_empty());
2679 match reused {
2680 Ok(true) => {
2681 return Some(TaskReuse::Reused {
2682 req: req.key.clone(),
2683 });
2684 }
2685 Ok(false) => {}
2686 Err(e) => {
2687 return Some(TaskReuse::Unreadable(format!(
2688 "the worktree's history cannot be read: {}",
2689 e.0
2690 )));
2691 }
2692 }
2693 if !record.is_current(&digest) {
2694 return Some(TaskReuse::Lapsed {
2695 req: req.key.clone(),
2696 frozen: record.wording_digest.clone(),
2697 current: digest,
2698 });
2699 }
2700 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2701 return Some(TaskReuse::Tampered(format!(
2702 "{}'s frozen commands are missing from the state directory",
2703 req.key
2704 )));
2705 };
2706 let manifest = manifest_digest(
2707 frozen
2708 .tests
2709 .iter()
2710 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2711 );
2712 let same_files = frozen.tests.len() == record.visible.len()
2713 && frozen.tests.iter().all(|t| {
2714 record
2715 .visible
2716 .iter()
2717 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2718 });
2719 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2720 return Some(TaskReuse::Tampered(format!(
2721 "{}'s frozen tests no longer match the manifest review.json names",
2722 req.key
2723 )));
2724 }
2725 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2728 return Some(TaskReuse::Tampered(format!(
2729 "{}'s frozen set names another approval than review.json{}",
2730 req.key,
2731 if approval.is_none() {
2732 " (which records none)"
2733 } else {
2734 ""
2735 }
2736 )));
2737 }
2738 let sealed = record
2739 .held_out_sealed
2740 .as_ref()
2741 .map(|h| format!("sha256:{}", h.manifest_sha256));
2742 if sealed != entry.held_out_sha256 {
2743 return Some(TaskReuse::Tampered(format!(
2744 "{}'s frozen held-out set is not the one review.json names",
2745 req.key
2746 )));
2747 }
2748 let tests = frozen
2749 .tests
2750 .iter()
2751 .map(|t| crate::pipeline::ReusedTest {
2752 path: t.path.clone(),
2753 cites: t.scenarios.clone(),
2754 argv: t.argv.clone(),
2755 green: false,
2756 })
2757 .collect();
2758 Some(TaskReuse::Ready {
2759 record: Box::new(record),
2760 files,
2761 tests,
2762 manifest,
2763 })
2764}
2765
2766pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2769 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2770 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2771 .ok()
2772 .and_then(|t| serde_json::from_str(&t).ok())
2773 .unwrap_or_default();
2774 let key = format!("{req}={frozen}");
2775 if seen.contains(&key) {
2776 return false;
2777 }
2778 seen.push(key);
2779 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2780 true
2781}
2782
2783#[must_use]
2785pub fn autopilot_denials(
2786 task: &str,
2787 tool_calls: &[Value],
2788) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2789 tool_calls
2790 .iter()
2791 .enumerate()
2792 .filter(|(_, c)| {
2793 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2794 })
2795 .map(|(i, c)| {
2796 let object: Option<tools::policy::Object> =
2797 serde_json::from_value(c["object"].clone()).ok();
2798 speccoder_core::oracle::AutopilotDenial {
2799 task: task.to_owned(),
2800 turn: c["turn"]
2801 .as_u64()
2802 .and_then(|n| u32::try_from(n).ok())
2803 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2804 class: object
2805 .as_ref()
2806 .map(|o| o.class().as_str().to_owned())
2807 .unwrap_or_default(),
2808 object: object.as_ref().map_or_else(
2809 || {
2810 c["arguments"]["path"]
2811 .as_str()
2812 .map(str::to_owned)
2813 .or_else(|| {
2814 c["arguments"]["argv"].as_array().map(|a| {
2815 a.iter()
2816 .filter_map(Value::as_str)
2817 .collect::<Vec<_>>()
2818 .join(" ")
2819 })
2820 })
2821 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2822 },
2823 tools::policy::Object::describe,
2824 ),
2825 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2826 }
2827 })
2828 .collect()
2829}
2830
2831#[must_use]
2833pub fn last_denials(
2834 project_state: &Path,
2835 unit: &str,
2836) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2837 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2838 .ok()
2839 .and_then(|t| serde_json::from_str(&t).ok())
2840 .unwrap_or_default()
2841}
2842
2843pub(crate) fn record_denials(
2845 project_state: &Path,
2846 unit: &str,
2847 denials: &[speccoder_core::oracle::AutopilotDenial],
2848) {
2849 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2850 if denials.is_empty() {
2851 let _ = std::fs::remove_file(file);
2852 return;
2853 }
2854 let _ = write_atomic(
2855 &file,
2856 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2857 );
2858}
2859
2860#[cfg(test)]
2861mod tests {
2862 use super::*;
2863
2864 #[test]
2867 fn each_scenario_is_placed_in_a_free_file_of_its_own() {
2868 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2869 for (path, text) in [
2870 ("pytest.ini", "[pytest]\n"),
2871 (
2872 "tests/test_acceptance_us2.py",
2873 "def test_t014():\n pass\n",
2874 ),
2875 (
2876 "tests/test_acceptance_us2_1.py",
2877 "def test_t017():\n pass\n",
2878 ),
2879 ] {
2880 std::fs::create_dir_all(d.path().join(path).parent().unwrap()).unwrap();
2881 std::fs::write(d.path().join(path), text).unwrap();
2882 }
2883 let scenario = |id: &str| speccoder_core::spec::ScenarioRef {
2884 id: id.to_owned(),
2885 text: String::new(),
2886 };
2887 let req = OracleRequirement {
2888 key: "US2".to_owned(),
2889 title: "File input".to_owned(),
2890 text: String::new(),
2891 scenarios: vec![scenario("US2-1"), scenario("US2-2")],
2892 };
2893 let got: Vec<(String, String)> = places(d.path(), d.path(), &req)
2894 .into_iter()
2895 .map(|p| (p.scenario, p.path))
2896 .collect();
2897 assert_eq!(
2898 got,
2899 [
2900 (
2901 "US2-1".to_owned(),
2902 "tests/test_acceptance_us2_1_2.py".to_owned()
2903 ),
2904 (
2905 "US2-2".to_owned(),
2906 "tests/test_acceptance_us2_2.py".to_owned()
2907 ),
2908 ]
2909 );
2910 }
2911
2912 fn draft(content: &str) -> Draft {
2913 Draft {
2914 requirement: DraftRequirement {
2915 key: "US1".to_owned(),
2916 title: "Greet".to_owned(),
2917 text: "### User Story 1 - Greet".to_owned(),
2918 sha256: "sha256:w".to_owned(),
2919 },
2920 samples: 2,
2921 tests: vec![DraftTest {
2922 path: "tests/acceptance_us1.rs".to_owned(),
2923 sha256: digest_of(content),
2924 scenarios: vec!["US1-1".to_owned()],
2925 argv: vec!["cargo".to_owned(), "test".to_owned()],
2926 red_class: RedClass::Assertion,
2927 authored_by: AuthoredBy::Model,
2928 edited_by: None,
2929 }],
2930 discarded: Vec::new(),
2931 rejected: Vec::new(),
2932 expectations: Samples::default(),
2933 untestable: Samples::default(),
2934 ambiguities: Vec::new(),
2935 held_out: HeldOutSummary {
2936 state: "absent".to_owned(),
2937 count: 0,
2938 manifest_sha256: None,
2939 reason: None,
2940 discarded: Vec::new(),
2941 },
2942 sealed: None,
2943 notes: Vec::new(),
2944 runs: Vec::new(),
2945 context: Vec::new(),
2946 }
2947 }
2948
2949 #[test]
2950 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2951 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2952 let d = draft("#[test] fn t() {}");
2953 let files = vec![(
2954 "tests/acceptance_us1.rs".to_owned(),
2955 "#[test] fn t() {}".to_owned(),
2956 )];
2957 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2958 let (back, contents) = load(state.path(), "001-greet", "US1")
2959 .unwrap_or_else(|e| panic!("{e:?}"))
2960 .unwrap_or_else(|| panic!("stored"));
2961 assert_eq!(back, d);
2962 assert_eq!(contents, files);
2963 std::fs::write(
2964 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2965 "changed",
2966 )
2967 .unwrap_or_else(|e| panic!("{e}"));
2968 assert!(load(state.path(), "001-greet", "US1").is_err());
2969 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2970 assert!(
2971 load(state.path(), "001-greet", "US2")
2972 .unwrap_or_default()
2973 .is_none()
2974 );
2975 }
2976
2977 #[test]
2978 fn the_manifest_names_paths_contents_and_commands() {
2979 let argv = vec!["cargo".to_owned()];
2980 let other = vec!["make".to_owned()];
2981 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2982 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2983 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2984 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2985 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2986 assert_eq!(
2987 both,
2988 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2989 );
2990 let mut d = draft("x");
2991 assert!(d.manifest().is_some());
2992 d.tests[0].red_class = RedClass::Passed;
2993 assert!(d.manifest().is_none());
2994 assert_eq!(slug("cap: a / b"), "cap__a___b");
2995 }
2996
2997 #[test]
2998 fn a_lapse_is_noted_once() {
2999 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
3000 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
3001 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
3002 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
3003 }
3004}