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 said = format!("{}\n{}", first.stdout_tail, first.stderr_tail);
1388 if crate::integrity::invalid_escape(&said, &[path.as_str()]).is_some() {
1389 let _ = std::fs::remove_file(&full);
1390 out.push(Err("invalid_escape"));
1391 continue;
1392 }
1393 let class = classify_red(&first, summary.as_ref());
1394 let class = if !class.is_red() {
1395 class
1396 } else {
1397 let (second, summary) = run();
1398 if classify_red(&second, summary.as_ref()).is_red() {
1399 class
1400 } else {
1401 RedClass::Flaky
1402 }
1403 };
1404 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1405 out.push(Ok(class));
1406 }
1407 Ok(out)
1408 }
1409}
1410
1411fn keep(
1413 ctx: &Ctx,
1414 bench: &Bench,
1415 answer: &Value,
1416 req: &OracleRequirement,
1417 taken: &[(String, Vec<String>)],
1418) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1419 let (valid, refused) =
1420 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios, None);
1421 let mut discarded: Vec<OracleDiscarded> = refused
1422 .into_iter()
1423 .map(|r| OracleDiscarded {
1424 path: r.path,
1425 why: r.why.to_owned(),
1426 })
1427 .collect();
1428 let set_argv: Vec<String> = crate::pipeline::acceptance::argv_of(&answer["argv"]);
1429 let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1430 let commands = crate::layout::commands(&bench.project);
1432 let via_command = !commands.is_empty() && crate::layout::via_command(&req.text);
1433 let mut kept = Vec::new();
1434 for f in valid {
1435 let mut discard = |why: &str| {
1436 discarded.push(OracleDiscarded {
1437 path: f.path.clone(),
1438 why: why.to_owned(),
1439 });
1440 };
1441 if f.cited.is_empty() {
1442 discard("cites_no_scenario");
1443 continue;
1444 }
1445 if via_command && !commands.iter().any(|c| c.run_by(&f.content)) {
1446 discard("no_command");
1447 continue;
1448 }
1449 if taken.iter().any(|(p, _)| *p == f.path)
1450 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1451 {
1452 discard("exists");
1453 continue;
1454 }
1455 let argv = if f.argv.is_empty() {
1456 set_argv.clone()
1457 } else {
1458 f.argv.clone()
1459 };
1460 if taken.iter().any(|(_, a)| *a == argv)
1463 || kept
1464 .iter()
1465 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1466 {
1467 discard("command_shared");
1468 continue;
1469 }
1470 if argv.is_empty()
1471 || !crate::pipeline::test_command_allowed(
1472 &ctx.policy,
1473 Some(&layout),
1474 &argv,
1475 &[f.path.as_str()],
1476 )
1477 {
1478 discard("command_not_allowed");
1479 continue;
1480 }
1481 if kept.len() + taken.len() >= LIMIT {
1482 discard("limit");
1483 continue;
1484 }
1485 kept.push((f.path, f.content, f.cited, argv));
1486 }
1487 (kept, discarded)
1488}
1489
1490fn write(
1493 ctx: &Ctx,
1494 req: &OracleRequirement,
1495 provider: Option<Arc<dyn ModelProvider>>,
1496 options: &crate::RunOptions,
1497) -> Outcome {
1498 let samples = ctx.config.oracle_samples.clamp(1, 2);
1499 let author = Author::open(ctx, req, provider, options)?;
1500 let bench = Bench::open(ctx)?;
1501 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1504 Box::new(
1505 refuse(
1506 CommandStatus::NeedsHuman,
1507 "ORACLE-TAMPERED",
1508 &format!(
1509 "{}: writing anew would lose the rejections and notes it holds",
1510 t.0
1511 ),
1512 )
1513 .with_fix(vec![format!(
1514 "→ restore it, or remove {} to start {}'s tests over",
1515 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1516 req.key
1517 )]),
1518 )
1519 })?;
1520 let (rejected, mut notes) = earlier
1521 .as_ref()
1522 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1523 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1524 .unwrap_or_default();
1525 notes.dedup();
1526 let told: Vec<String> = rejected
1527 .iter()
1528 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1529 .chain(notes.iter().cloned())
1530 .collect();
1531 let (draft, contents) = draft(
1532 ctx,
1533 req,
1534 &author,
1535 &bench,
1536 samples,
1537 &told,
1538 &[],
1539 rejected,
1540 notes,
1541 true,
1542 options,
1543 )?;
1544 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1545 let red = draft.red().count();
1546 let open = draft.open_ambiguities().count();
1547 Ok(format!(
1548 "wrote {} test(s) for {}: {red} red{}{}",
1549 draft.tests.len(),
1550 req.key,
1551 if draft.held_out.state == "kept" {
1552 format!(", {} held out", draft.held_out.count)
1553 } else {
1554 String::new()
1555 },
1556 if open > 0 {
1557 format!(
1558 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1559 req.key, req.key
1560 )
1561 } else {
1562 String::new()
1563 }
1564 ))
1565}
1566
1567fn places(
1571 project: &Path,
1572 root: &Path,
1573 req: &OracleRequirement,
1574) -> Vec<substrate::acceptance::ScenarioPlace> {
1575 let layout = crate::layout::detect(project, &crate::verify::check_argvs(root));
1576 req.scenarios
1577 .iter()
1578 .filter_map(|s| {
1579 let name = s.id.replace('-', "_");
1580 let path = (1..10)
1581 .filter_map(|n| {
1582 let name = if n == 1 {
1583 name.clone()
1584 } else {
1585 format!("{name}_{n}")
1586 };
1587 layout.acceptance_file(&name, None)
1588 })
1589 .find(|p| !project.join(p).exists())?;
1590 Some(substrate::acceptance::ScenarioPlace {
1591 scenario: s.id.clone(),
1592 command: layout.command_for(&path).unwrap_or_default(),
1593 path,
1594 })
1595 })
1596 .collect()
1597}
1598
1599#[allow(clippy::too_many_arguments)]
1602fn draft(
1603 ctx: &Ctx,
1604 req: &OracleRequirement,
1605 author: &Author<'_>,
1606 bench: &Bench,
1607 samples: u8,
1608 told: &[String],
1609 refused: &[String],
1610 rejected: Vec<OracleRejected>,
1611 notes: Vec<String>,
1612 with_held_out: bool,
1613 options: &crate::RunOptions,
1614) -> Result<Loaded, Box<CommandResult>> {
1615 let (files, manifest) = bench.files();
1616 let places = places(&bench.project, &ctx.root, req);
1617 let of = u32::from(samples);
1618 stage(options, &req.key, "writing", 1, of);
1619 let (first, run1, mut context) = author
1620 .call(req, &files, &manifest, told, refused, None, 1, &places)
1621 .map_err(failed)?;
1622 let read = |answer: &Value| {
1625 let got = expectations(answer, req);
1626 if samples >= 2 {
1627 got.map_err(failed)
1628 } else {
1629 Ok(got.unwrap_or_default())
1630 }
1631 };
1632 let (e1, u1) = read(&first)?;
1633 let mut runs = vec![run1];
1634 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1635 if samples >= 2 {
1636 stage(options, &req.key, "writing", 2, of);
1637 let (second, run2, more) = author
1638 .call(req, &files, &manifest, told, refused, None, 2, &places)
1639 .map_err(failed)?;
1640 (e2, u2) = read(&second)?;
1641 runs.push(run2);
1642 for c in more {
1643 if !context.contains(&c) {
1644 context.push(c);
1645 }
1646 }
1647 stage(options, &req.key, "comparing", 1, 1);
1648 }
1649 let ambiguities = if samples >= 2 {
1650 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1651 } else {
1652 Vec::new()
1653 };
1654 let (kept, mut discarded) = keep(ctx, bench, &first, req, &[]);
1655 stage(options, &req.key, "checking_red", 1, 1);
1656 let plan: Vec<(String, String, Vec<String>)> = kept
1657 .iter()
1658 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1659 .collect();
1660 let classes = bench.red(&plan).map_err(failed)?;
1661 let mut tests: Vec<DraftTest> = Vec::new();
1662 let mut contents: Vec<(String, String)> = Vec::new();
1663 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1664 match class {
1665 Ok(red_class) => {
1666 tests.push(DraftTest {
1667 path: path.clone(),
1668 sha256: digest_of(&content),
1669 scenarios: cited,
1670 argv,
1671 red_class,
1672 authored_by: AuthoredBy::Model,
1673 edited_by: None,
1674 });
1675 contents.push((path, content));
1676 }
1677 Err(why) => discarded.push(OracleDiscarded {
1678 path,
1679 why: why.to_owned(),
1680 }),
1681 }
1682 }
1683 let (held_out, sealed) = if with_held_out {
1685 held_out(ctx, author, bench, req, &tests, options)
1686 } else {
1687 (
1688 HeldOutSummary {
1689 state: "off".to_owned(),
1690 count: 0,
1691 manifest_sha256: None,
1692 reason: None,
1693 discarded: Vec::new(),
1694 },
1695 None,
1696 )
1697 };
1698 let draft = Draft {
1699 requirement: DraftRequirement {
1700 key: req.key.clone(),
1701 title: req.title.clone(),
1702 text: req.text.clone(),
1703 sha256: req.digest(),
1704 },
1705 samples,
1706 tests,
1707 discarded,
1708 rejected,
1709 expectations: Samples {
1710 sample1: e1,
1711 sample2: e2,
1712 },
1713 untestable: Samples {
1714 sample1: u1,
1715 sample2: u2,
1716 },
1717 ambiguities,
1718 held_out,
1719 sealed,
1720 notes,
1721 runs,
1722 context,
1723 };
1724 Ok((draft, contents))
1725}
1726
1727fn held_out(
1731 ctx: &Ctx,
1732 author: &Author<'_>,
1733 bench: &Bench,
1734 req: &OracleRequirement,
1735 tests: &[DraftTest],
1736 options: &crate::RunOptions,
1737) -> (HeldOutSummary, Option<SealedRef>) {
1738 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1739 state: state.to_owned(),
1740 count: 0,
1741 manifest_sha256: None,
1742 reason: reason.map(str::to_owned),
1743 discarded: Vec::new(),
1744 };
1745 if !ctx.config.held_out {
1746 return (summary("off", None), None);
1747 }
1748 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1749 if red.is_empty() {
1750 return (summary("absent", Some("not_red")), None);
1751 }
1752 stage(options, &req.key, "held_out", 1, 1);
1753 let visible = crate::pipeline::PreparedAcceptance {
1754 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1755 argv: red.first().map(|t| t.argv.clone()),
1756 more_argv: Vec::new(),
1757 red: true,
1758 red_class: Some(RedClass::Assertion),
1759 held_out: None,
1760 generation_failed: None,
1761 skip_cause: None,
1762 };
1763 let task = format!("req-{}", req.key);
1764 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1765 let (report, sha) = crate::pipeline::prepare_held_out(
1766 &crate::pipeline::AcceptanceSetup {
1767 rt: &author.rt,
1768 plane: &author.plane,
1769 project: &bench.project,
1770 worktree: &bench.root,
1771 tmp: &bench.tmp,
1772 policy: &ctx.policy,
1773 feature: &ctx.feature,
1774 task: &task,
1775 bundle: author.bundle.clone(),
1776 scenarios: &req.scenarios,
1777 instructions: &author.instructions,
1778 runner: &bench.runner,
1779 layout: None,
1780 told: &[],
1781 related: &[],
1782 oracle: true,
1783 },
1784 &visible,
1785 &req.scenarios,
1786 &store,
1787 false,
1788 );
1789 let total =
1791 crate::pipeline::commit::add_usage(author.spent.borrow().as_ref(), report.get("usage"));
1792 *author.spent.borrow_mut() = total;
1793 match sha {
1794 Some(sha) => {
1795 let count = report["kept"]
1796 .as_u64()
1797 .and_then(|n| usize::try_from(n).ok())
1798 .unwrap_or(0);
1799 (
1800 HeldOutSummary {
1801 state: "kept".to_owned(),
1802 count,
1803 manifest_sha256: Some(format!("sha256:{sha}")),
1804 reason: None,
1805 discarded: Vec::new(),
1806 },
1807 Some(SealedRef {
1808 key: req.key.clone(),
1809 manifest_sha256: sha,
1810 count,
1811 }),
1812 )
1813 }
1814 None => (
1815 HeldOutSummary {
1816 discarded: report["discarded"]
1817 .as_array()
1818 .into_iter()
1819 .flatten()
1820 .filter_map(|d| d["reason"].as_str().map(str::to_owned))
1821 .collect(),
1822 ..summary(
1823 "absent",
1824 Some(report["reason"].as_str().unwrap_or("none_kept")),
1825 )
1826 },
1827 None,
1828 ),
1829 }
1830}
1831
1832fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1833 let Some(path) = r.path else {
1834 return Err(usage("name the test to reject (--reject PATH)"));
1835 };
1836 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1837 return Err(usage(
1838 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1839 ));
1840 };
1841 let (mut d, contents) = existing(ctx, req)?;
1842 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1843 return Err(usage(&format!("{} has no test {path}", req.key)));
1844 };
1845 d.tests.remove(at);
1846 d.rejected.push(OracleRejected {
1847 path: path.to_owned(),
1848 reason: reason.to_owned(),
1849 by: by.to_owned(),
1850 at: now(),
1851 });
1852 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1853 Ok(format!(
1854 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1855 req.key, req.key
1856 ))
1857}
1858
1859fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1860 let Some(path) = r.path else {
1861 return Err(usage("name the test to edit (--edit PATH)"));
1862 };
1863 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1864 return Err(usage("an edit needs the test's new content (--from FILE)"));
1865 };
1866 let (mut d, mut contents) = existing(ctx, req)?;
1867 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1868 return Err(usage(&format!("{} has no test {path}", req.key)));
1869 };
1870 let bench = Bench::open(ctx)?;
1871 let plan = [(
1872 path.to_owned(),
1873 content.to_owned(),
1874 d.tests[at].argv.clone(),
1875 )];
1876 let checked = bench
1877 .red(&plan)
1878 .map_err(failed)?
1879 .pop()
1880 .unwrap_or(Ok(RedClass::Unclassified));
1881 let class = checked.unwrap_or(RedClass::Unclassified);
1883 let t = &mut d.tests[at];
1884 t.sha256 = digest_of(content);
1885 t.red_class = class;
1886 t.authored_by = AuthoredBy::Person;
1887 t.edited_by = Some(by.to_owned());
1888 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1889 c.1 = content.to_owned();
1890 }
1891 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1892 Ok(format!(
1893 "edited {path} of {}: {}",
1894 req.key,
1895 if class.is_red() {
1896 format!("red ({})", crate::pipeline::class_word(class))
1897 } else if checked.is_err() {
1898 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1899 } else {
1900 format!(
1901 "not red ({}) — no evidence, it cannot be frozen",
1902 crate::pipeline::class_word(class)
1903 )
1904 }
1905 ))
1906}
1907
1908#[allow(clippy::too_many_arguments)]
1911fn rewrite(
1912 ctx: &Ctx,
1913 req: &OracleRequirement,
1914 mut d: Draft,
1915 mut contents: Vec<(String, String)>,
1916 replaced: &[String],
1917 note: String,
1918 again: Option<(&str, &str)>,
1919 only: Option<&str>,
1920 provider: Option<Arc<dyn ModelProvider>>,
1921 options: &crate::RunOptions,
1922) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1923 let author = Author::open(ctx, req, provider, options)?;
1924 let bench = Bench::open(ctx)?;
1925 let (files, manifest) = bench.files();
1926 d.notes.push(note);
1927 let told = d.all_notes();
1928 stage(options, &req.key, "writing", 1, 1);
1929 let (answer, run, context) = author
1930 .call(req, &files, &manifest, &told, &[], again, 1, &[])
1931 .map_err(failed)?;
1932 d.runs.push(run);
1933 for c in context {
1934 if !d.context.contains(&c) {
1935 d.context.push(c);
1936 }
1937 }
1938 let keep_paths: Vec<(String, Vec<String>)> = d
1939 .tests
1940 .iter()
1941 .filter(|t| !replaced.contains(&t.path))
1942 .map(|t| (t.path.clone(), t.argv.clone()))
1943 .collect();
1944 let mut answer = answer;
1946 if let Some(files) = answer["files"].as_array_mut() {
1947 files.retain(|f| {
1948 only.is_none_or(|s| {
1949 f["scenarios"]
1950 .as_array()
1951 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1952 })
1953 });
1954 }
1955 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1956 if new.is_empty() {
1957 return Err(failed(format!(
1958 "the test author wrote no usable test{}",
1959 discarded
1960 .first()
1961 .map(|x| format!(" ({} {})", x.path, x.why))
1962 .unwrap_or_default()
1963 )));
1964 }
1965 d.tests.retain(|t| !replaced.contains(&t.path));
1966 contents.retain(|(p, _)| !replaced.contains(p));
1967 d.discarded.extend(discarded);
1968 stage(options, &req.key, "checking_red", 1, 1);
1969 let plan: Vec<(String, String, Vec<String>)> = new
1971 .iter()
1972 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1973 .collect();
1974 let classes = bench.red(&plan).map_err(failed)?;
1975 let mut written = Vec::new();
1976 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1977 let red_class = match class {
1978 Ok(c) => c,
1979 Err(why) => {
1980 d.discarded.push(OracleDiscarded {
1981 path,
1982 why: why.to_owned(),
1983 });
1984 continue;
1985 }
1986 };
1987 d.tests.push(DraftTest {
1988 sha256: digest_of(&content),
1989 scenarios: cited,
1990 argv,
1991 red_class,
1992 authored_by: AuthoredBy::Model,
1993 edited_by: None,
1994 path: path.clone(),
1995 });
1996 contents.push((path.clone(), content));
1997 written.push(path);
1998 }
1999 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
2000 Ok((d, written))
2001}
2002
2003fn again(
2004 ctx: &Ctx,
2005 req: &OracleRequirement,
2006 r: &Request<'_>,
2007 _by: &str,
2008 provider: Option<Arc<dyn ModelProvider>>,
2009 options: &crate::RunOptions,
2010) -> Outcome {
2011 let Some(path) = r.path else {
2012 return Err(usage("name the test to write again (--again PATH)"));
2013 };
2014 let comment = r
2015 .text
2016 .map(str::trim)
2017 .filter(|t| !t.is_empty())
2018 .unwrap_or("write it again");
2019 let (d, contents) = existing(ctx, req)?;
2020 let Some(content) = contents
2021 .iter()
2022 .find(|(p, _)| p == path)
2023 .map(|(_, c)| c.clone())
2024 else {
2025 return Err(usage(&format!("{} has no test {path}", req.key)));
2026 };
2027 let (d, written) = rewrite(
2028 ctx,
2029 req,
2030 d,
2031 contents,
2032 &[path.to_owned()],
2033 format!("Comment on `{path}`: {comment}"),
2034 Some((path, &content)),
2035 None,
2036 provider,
2037 options,
2038 )?;
2039 let red = d
2040 .tests
2041 .iter()
2042 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
2043 .count();
2044 Ok(format!(
2045 "wrote {path} of {} again: {}",
2046 req.key,
2047 if written.is_empty() {
2048 "nothing kept — its command is red without it, so it decides nothing".to_owned()
2049 } else if red == written.len() {
2050 "red".to_owned()
2051 } else {
2052 "not red — no evidence, it cannot be frozen".to_owned()
2053 }
2054 ))
2055}
2056
2057fn reading(
2058 ctx: &Ctx,
2059 req: &OracleRequirement,
2060 r: &Request<'_>,
2061 by: &str,
2062 provider: Option<Arc<dyn ModelProvider>>,
2063 options: &crate::RunOptions,
2064) -> Outcome {
2065 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
2066 return Err(usage(
2067 "name the reading and the scenario (--reading A|B US1-2)",
2068 ));
2069 };
2070 let (mut d, contents) = existing(ctx, req)?;
2071 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
2072 return Err(usage(&format!(
2073 "{} has no ambiguity on {scenario}",
2074 req.key
2075 )));
2076 };
2077 item.resolution = Some(Resolution {
2078 reading: choice,
2079 by: by.to_owned(),
2080 at: now(),
2081 });
2082 let meant = if choice == Reading::A {
2083 item.a.clone()
2084 } else {
2085 item.b.clone()
2086 };
2087 if choice == Reading::A {
2088 d.notes.push(format!(
2089 "For {scenario} the person means reading A: given {}, expect {}",
2090 meant.given, meant.expect
2091 ));
2092 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
2093 return Ok(format!(
2094 "{scenario} of {}: reading A is meant — the tests stay",
2095 req.key
2096 ));
2097 }
2098 let replaced: Vec<String> = d
2099 .tests
2100 .iter()
2101 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
2102 .map(|t| t.path.clone())
2103 .collect();
2104 let (_, written) = rewrite(
2105 ctx,
2106 req,
2107 d,
2108 contents,
2109 &replaced,
2110 format!(
2111 "For {scenario} the person means reading B: given {}, expect {}",
2112 meant.given, meant.expect
2113 ),
2114 None,
2115 Some(scenario),
2116 provider,
2117 options,
2118 )?;
2119 Ok(format!(
2120 "{scenario} of {}: reading B is meant — wrote {} anew",
2121 req.key,
2122 written.join(", ")
2123 ))
2124}
2125
2126#[derive(Debug, Clone)]
2129pub struct Freezable {
2130 pub req: OracleRequirement,
2132 pub draft: Draft,
2134 pub contents: Vec<(String, String)>,
2136 pub manifest: String,
2138}
2139
2140pub fn freezable(
2143 project_state: &Path,
2144 unit: &str,
2145 requirements: &[OracleRequirement],
2146) -> Result<Vec<Freezable>, Tampered> {
2147 let mut out = Vec::new();
2148 for r in requirements {
2149 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
2150 continue;
2151 };
2152 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
2153 continue;
2154 }
2155 let Some(manifest) = draft.manifest() else {
2156 continue;
2157 };
2158 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
2159 out.push(Freezable {
2160 req: r.clone(),
2161 contents: contents
2162 .into_iter()
2163 .filter(|(p, _)| red.contains(p))
2164 .collect(),
2165 draft,
2166 manifest,
2167 });
2168 }
2169 Ok(out)
2170}
2171
2172pub fn freeze(
2176 project_state: &Path,
2177 unit: &str,
2178 f: &Freezable,
2179 approval_digest: &str,
2180 decided: Decided,
2181 producer: Producer,
2182) -> Result<PreFrozenAcceptance, String> {
2183 let red: Vec<&DraftTest> = f.draft.red().collect();
2184 freeze_tests(
2185 project_state,
2186 unit,
2187 f,
2188 &red,
2189 approval_digest,
2190 decided,
2191 producer,
2192 )
2193}
2194
2195fn freeze_tests(
2197 project_state: &Path,
2198 unit: &str,
2199 f: &Freezable,
2200 red: &[&DraftTest],
2201 approval_digest: &str,
2202 decided: Decided,
2203 producer: Producer,
2204) -> Result<PreFrozenAcceptance, String> {
2205 let sealed = match &f.draft.sealed {
2206 Some(s) => {
2207 crate::held_out::open_sealed(project_state, unit, s)
2208 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
2209 Some(s.clone())
2210 }
2211 None => None,
2212 };
2213 let record = PreFrozenAcceptance {
2214 unit: unit.to_owned(),
2215 requirement: f.req.key.clone(),
2216 wording_digest: f.draft.requirement.sha256.clone(),
2217 approval_digest: approval_digest.to_owned(),
2218 visible: red
2219 .iter()
2220 .map(|t| VisibleTest {
2221 path: t.path.clone(),
2222 sha256: t.sha256.clone(),
2223 cites: t.scenarios.clone(),
2224 })
2225 .collect(),
2226 held_out_sealed: sealed,
2227 producer,
2228 decided,
2229 };
2230 crate::pre_frozen::store(project_state, &record, &f.contents)?;
2231 let frozen = Frozen {
2232 requirement_sha256: f.draft.requirement.sha256.clone(),
2233 wording: format!(
2234 "{}\n{}",
2235 f.draft.requirement.title, f.draft.requirement.text
2236 ),
2237 manifest_sha256: f.manifest.clone(),
2238 tests: red
2239 .iter()
2240 .map(|t| FrozenTest {
2241 path: t.path.clone(),
2242 sha256: t.sha256.clone(),
2243 argv: t.argv.clone(),
2244 scenarios: t.scenarios.clone(),
2245 })
2246 .collect(),
2247 };
2248 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2249 write_atomic(
2250 &dir(project_state, unit, &f.req.key).join("frozen.json"),
2251 &text,
2252 )?;
2253 Ok(record)
2254}
2255
2256fn discard_words(why: &str) -> &'static str {
2264 match why {
2265 "cites_no_scenario" => "cites no scenario of the list",
2266 "no_command" => {
2267 "never runs the project's command — the story's interface is the command, not a module behind it"
2268 }
2269 "command_shared" => {
2270 "shares its command with another test — each test needs the command that runs only it"
2271 }
2272 "command_not_allowed" => "gives a command the policy does not allow",
2273 "limit" => "is beyond the number of tests one answer may hold",
2274 "red_without_it" => "fails without its own file — its command runs other, failing tests",
2275 _ => crate::pipeline::acceptance::refusal_words(why),
2276 }
2277}
2278
2279fn not_kept(t: &DraftTest, many: bool) -> Option<String> {
2280 if many && t.scenarios.len() > 1 {
2281 return Some(format!(
2282 "it tests {} together — one file per scenario, so each turns green on its own",
2283 t.scenarios.join(", ")
2284 ));
2285 }
2286 (!usable(t)).then(|| {
2287 format!(
2288 "it did not fail on an assertion ({})",
2289 crate::pipeline::class_word(t.red_class)
2290 )
2291 })
2292}
2293
2294fn usable(t: &DraftTest) -> bool {
2295 match t.red_class {
2296 RedClass::Assertion | RedClass::Passed => true,
2297 RedClass::Absent => [".rs", ".go", ".java", ".kt", ".cs", ".swift"]
2298 .iter()
2299 .any(|e| t.path.ends_with(e)),
2300 _ => false,
2301 }
2302}
2303
2304#[derive(Debug)]
2308pub(crate) struct RunOracle {
2309 pub tests: Vec<FrozenTest>,
2312 pub contents: Vec<(String, String)>,
2314 pub contracts: Vec<Expectation>,
2316 pub untestable: Vec<Untestable>,
2318 pub usage: Option<Value>,
2320}
2321
2322#[allow(clippy::too_many_arguments)]
2329pub(crate) fn write_for_run(
2330 project: &Path,
2331 wt_root: &Path,
2332 feature: &str,
2333 state: &Path,
2334 story: &str,
2335 runner: sandbox::Runner,
2336 (rt, plane): (&tokio::runtime::Runtime, &substrate::Plane),
2337 (only, told): (&[String], &[String]),
2338 run: &str,
2339 options: &crate::RunOptions,
2340) -> Result<RunOracle, Box<CommandResult>> {
2341 let ctx = open(project, Some(feature), state)?;
2342 let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
2343 let whole = (story == ctx.feature && !requirements.iter().any(|r| r.key == story)).then(|| {
2345 OracleRequirement {
2346 key: story.to_owned(),
2347 title: ctx.feature.clone(),
2348 text: requirements
2349 .iter()
2350 .map(|r| format!("{}: {}\n{}", r.key, r.title, r.text))
2351 .collect::<Vec<_>>()
2352 .join("\n\n"),
2353 scenarios: requirements
2354 .iter()
2355 .flat_map(|r| r.scenarios.clone())
2356 .collect(),
2357 }
2358 });
2359 let Some(mut req) = whole.or_else(|| requirements.into_iter().find(|r| r.key == story)) else {
2360 return Err(Box::new(refuse(
2361 CommandStatus::NeedsHuman,
2362 "ORACLE-UNWRITTEN",
2363 &format!("{story} has no scenarios to write tests for"),
2364 )));
2365 };
2366 if !only.is_empty() {
2368 req.scenarios.retain(|s| only.contains(&s.id));
2369 }
2370 let _lock = UnitLock::take(&ctx.plane_dir, &ctx.feature)
2371 .map_err(|e| Box::new(refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e)))?;
2372 let author = Author::lent(&ctx, &req, rt, plane);
2373 let many = req.scenarios.len() > 1;
2374 let bench = Bench::here(&ctx, wt_root, runner);
2375 let kept = |d: &Draft| -> Vec<String> {
2376 let mut covered: Vec<String> = d
2377 .tests
2378 .iter()
2379 .filter(|t| not_kept(t, many).is_none())
2380 .flat_map(|t| t.scenarios.iter().cloned())
2381 .collect();
2382 covered.sort();
2383 covered.dedup();
2384 covered
2385 };
2386 let pass = |refused: &[String]| {
2389 draft(
2390 &ctx,
2391 &req,
2392 &author,
2393 &bench,
2394 1,
2395 told,
2396 refused,
2397 Vec::new(),
2398 told.to_vec(),
2399 false,
2400 options,
2401 )
2402 };
2403 let (mut draft, mut contents) = pass(&[])?;
2404 let untestable: Vec<String> = draft
2408 .untestable
2409 .sample1
2410 .iter()
2411 .map(|u| u.scenario.clone())
2412 .collect();
2413 let broke =
2414 !draft.discarded.is_empty() || draft.tests.iter().any(|t| not_kept(t, many).is_some());
2415 let left: Vec<String> = req
2416 .scenarios
2417 .iter()
2418 .map(|s| s.id.clone())
2419 .filter(|id| !kept(&draft).contains(id) && !untestable.contains(id))
2420 .collect();
2421 let opted_out = draft.tests.is_empty() && draft.discarded.is_empty() && !untestable.is_empty();
2422 if (broke && (!left.is_empty() || kept(&draft).is_empty())) || opted_out {
2423 let again: Vec<String> = if left.is_empty() {
2424 req.scenarios.iter().map(|s| s.id.clone()).collect()
2425 } else {
2426 left
2427 };
2428 let refused: Vec<String> = draft
2429 .discarded
2430 .iter()
2431 .map(|d| format!("Not kept `{}`: it {}", d.path, discard_words(&d.why)))
2432 .chain(draft.tests.iter().filter_map(|t| {
2433 not_kept(t, many).map(|why| format!("Not kept `{}`: {why}", t.path))
2434 }))
2435 .chain(opted_out.then(|| {
2436 format!(
2437 "Called untestable: {} — the story's code not existing yet never makes a scenario untestable; test it through the story's interface",
2438 untestable.join(", ")
2439 )
2440 }))
2441 .chain(std::iter::once(format!(
2442 "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",
2443 again.join(", ")
2444 )))
2445 .collect();
2446 let (second, more) = pass(&refused)?;
2447 if kept(&second).len() > kept(&draft).len() {
2448 (draft, contents) = (second, more);
2449 }
2450 }
2451 if only.is_empty() {
2453 let keep: Vec<DraftTest> = draft
2454 .tests
2455 .iter()
2456 .filter(|t| not_kept(t, many).is_none())
2457 .cloned()
2458 .collect();
2459 if !keep.is_empty() {
2460 (draft.held_out, draft.sealed) = held_out(&ctx, &author, &bench, &req, &keep, options);
2461 }
2462 }
2463 if only.is_empty() {
2465 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
2466 }
2467 let keep: Vec<&DraftTest> = draft
2468 .tests
2469 .iter()
2470 .filter(|t| not_kept(t, many).is_none())
2471 .collect();
2472 if keep.is_empty() && draft.tests.is_empty() && draft.discarded.is_empty() {
2475 return Ok(RunOracle {
2476 tests: Vec::new(),
2477 contents: Vec::new(),
2478 contracts: draft.expectations.sample1.clone(),
2479 untestable: draft.untestable.sample1.clone(),
2480 usage: author.spent.borrow().clone(),
2481 });
2482 }
2483 if keep.is_empty() {
2484 let why: Vec<String> = draft
2485 .discarded
2486 .iter()
2487 .map(|d| format!("`{}` {}", d.path, discard_words(&d.why)))
2488 .chain(
2489 draft
2490 .tests
2491 .iter()
2492 .filter_map(|t| not_kept(t, many).map(|why| format!("`{}`: {why}", t.path))),
2493 )
2494 .collect();
2495 return Err(Box::new(
2496 refuse(
2497 CommandStatus::NeedsHuman,
2498 "ORACLE-UNWRITTEN",
2499 &format!(
2500 "no test written for {story} could be used, asked twice: {}",
2501 why.join("; ")
2502 ),
2503 )
2504 .with_fix(vec![format!("speccoder review tests {story} --write")]),
2505 ));
2506 }
2507 let manifest = manifest_digest(
2508 keep.iter()
2509 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2510 );
2511 let paths: Vec<&str> = keep.iter().map(|t| t.path.as_str()).collect();
2512 let kept_contents: Vec<(String, String)> = contents
2513 .iter()
2514 .filter(|(p, _)| paths.contains(&p.as_str()))
2515 .cloned()
2516 .collect();
2517 let f = Freezable {
2518 req: req.clone(),
2519 draft: draft.clone(),
2520 contents: kept_contents.clone(),
2521 manifest,
2522 };
2523 if only.is_empty() {
2526 freeze_tests(
2527 &ctx.plane_dir,
2528 &ctx.feature,
2529 &f,
2530 &keep,
2531 &format!(
2532 "run:{run}{}",
2533 if only.is_empty() { "" } else { ":amendment" }
2534 ),
2535 Decided {
2536 by: "speccoder run".to_owned(),
2537 via: Via::Terminal,
2538 at: now(),
2539 },
2540 Producer::Run,
2541 )
2542 .map_err(failed)?;
2543 }
2544 Ok(RunOracle {
2545 tests: keep
2546 .iter()
2547 .map(|t| FrozenTest {
2548 path: t.path.clone(),
2549 sha256: t.sha256.clone(),
2550 argv: t.argv.clone(),
2551 scenarios: t.scenarios.clone(),
2552 })
2553 .collect(),
2554 contents: kept_contents,
2555 contracts: draft.expectations.sample1.clone(),
2556 untestable: draft.untestable.sample1.clone(),
2557 usage: author.spent.borrow().clone(),
2558 })
2559}
2560
2561pub(crate) fn refreeze(
2568 project_state: &Path,
2569 unit: &str,
2570 key: &str,
2571 tests: Vec<FrozenTest>,
2572) -> Result<(), String> {
2573 let mut frozen = load_frozen(project_state, unit, key)
2574 .ok_or_else(|| format!("{key} has no frozen oracle here"))?;
2575 frozen.manifest_sha256 = manifest_digest(
2576 tests
2577 .iter()
2578 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2579 );
2580 frozen.tests = tests;
2581 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2582 write_atomic(&dir(project_state, unit, key).join("frozen.json"), &text)
2583}
2584
2585#[derive(Debug)]
2587pub(crate) enum TaskReuse {
2588 Ready {
2590 record: Box<PreFrozenAcceptance>,
2592 files: Vec<(String, String)>,
2594 tests: Vec<crate::pipeline::ReusedTest>,
2596 manifest: String,
2598 },
2599 Reused {
2601 req: String,
2603 },
2604 Elsewhere {
2606 req: String,
2608 },
2609 Lapsed {
2611 req: String,
2613 frozen: String,
2615 current: String,
2617 },
2618 Tampered(String),
2620 Unreadable(String),
2622}
2623
2624pub(crate) fn for_task(
2629 project_state: &Path,
2630 project: &Path,
2631 wt_root: &Path,
2632 format: &dyn Adapter,
2633 snapshot: &ProjectSnapshot,
2634 scenarios: &[speccoder_core::spec::ScenarioRef],
2635) -> Option<TaskReuse> {
2636 let f = snapshot.feature.as_ref()?;
2637 let unit = f.feature.name.as_str();
2638 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2639 Ok(x) => x,
2640 Err(e) => return Some(TaskReuse::Unreadable(e)),
2642 };
2643 if entries.is_empty() {
2644 return None;
2645 }
2646 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2647 let requirements = format.oracle_requirements(snapshot);
2648 let (req, entry) = requirements.iter().find_map(|r| {
2649 let e = entries.get(&r.key)?;
2650 r.scenarios
2651 .iter()
2652 .any(|s| ids.contains(&s.id.as_str()))
2653 .then_some((r, e))
2654 })?;
2655 let digest = req.digest();
2656 if entry.requirement_sha256 != digest {
2657 return Some(TaskReuse::Lapsed {
2658 req: req.key.clone(),
2659 frozen: entry.requirement_sha256.clone(),
2660 current: digest,
2661 });
2662 }
2663 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2664 Ok(Some(x)) => x,
2665 Ok(None) => {
2666 return Some(TaskReuse::Elsewhere {
2667 req: req.key.clone(),
2668 });
2669 }
2670 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2671 };
2672 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2676 let reused = crate::git::Git::at(wt_root)
2677 .run(&[
2678 "log",
2679 "--format=%H",
2680 "--fixed-strings",
2681 &format!("--grep={needle}"),
2682 "HEAD",
2683 ])
2684 .map(|out| !out.is_empty());
2685 match reused {
2686 Ok(true) => {
2687 return Some(TaskReuse::Reused {
2688 req: req.key.clone(),
2689 });
2690 }
2691 Ok(false) => {}
2692 Err(e) => {
2693 return Some(TaskReuse::Unreadable(format!(
2694 "the worktree's history cannot be read: {}",
2695 e.0
2696 )));
2697 }
2698 }
2699 if !record.is_current(&digest) {
2700 return Some(TaskReuse::Lapsed {
2701 req: req.key.clone(),
2702 frozen: record.wording_digest.clone(),
2703 current: digest,
2704 });
2705 }
2706 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2707 return Some(TaskReuse::Tampered(format!(
2708 "{}'s frozen commands are missing from the state directory",
2709 req.key
2710 )));
2711 };
2712 let manifest = manifest_digest(
2713 frozen
2714 .tests
2715 .iter()
2716 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2717 );
2718 let same_files = frozen.tests.len() == record.visible.len()
2719 && frozen.tests.iter().all(|t| {
2720 record
2721 .visible
2722 .iter()
2723 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2724 });
2725 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2726 return Some(TaskReuse::Tampered(format!(
2727 "{}'s frozen tests no longer match the manifest review.json names",
2728 req.key
2729 )));
2730 }
2731 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2734 return Some(TaskReuse::Tampered(format!(
2735 "{}'s frozen set names another approval than review.json{}",
2736 req.key,
2737 if approval.is_none() {
2738 " (which records none)"
2739 } else {
2740 ""
2741 }
2742 )));
2743 }
2744 let sealed = record
2745 .held_out_sealed
2746 .as_ref()
2747 .map(|h| format!("sha256:{}", h.manifest_sha256));
2748 if sealed != entry.held_out_sha256 {
2749 return Some(TaskReuse::Tampered(format!(
2750 "{}'s frozen held-out set is not the one review.json names",
2751 req.key
2752 )));
2753 }
2754 let tests = frozen
2755 .tests
2756 .iter()
2757 .map(|t| crate::pipeline::ReusedTest {
2758 path: t.path.clone(),
2759 cites: t.scenarios.clone(),
2760 argv: t.argv.clone(),
2761 green: false,
2762 })
2763 .collect();
2764 Some(TaskReuse::Ready {
2765 record: Box::new(record),
2766 files,
2767 tests,
2768 manifest,
2769 })
2770}
2771
2772pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2775 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2776 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2777 .ok()
2778 .and_then(|t| serde_json::from_str(&t).ok())
2779 .unwrap_or_default();
2780 let key = format!("{req}={frozen}");
2781 if seen.contains(&key) {
2782 return false;
2783 }
2784 seen.push(key);
2785 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2786 true
2787}
2788
2789#[must_use]
2791pub fn autopilot_denials(
2792 task: &str,
2793 tool_calls: &[Value],
2794) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2795 tool_calls
2796 .iter()
2797 .enumerate()
2798 .filter(|(_, c)| {
2799 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2800 })
2801 .map(|(i, c)| {
2802 let object: Option<tools::policy::Object> =
2803 serde_json::from_value(c["object"].clone()).ok();
2804 speccoder_core::oracle::AutopilotDenial {
2805 task: task.to_owned(),
2806 turn: c["turn"]
2807 .as_u64()
2808 .and_then(|n| u32::try_from(n).ok())
2809 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2810 class: object
2811 .as_ref()
2812 .map(|o| o.class().as_str().to_owned())
2813 .unwrap_or_default(),
2814 object: object.as_ref().map_or_else(
2815 || {
2816 c["arguments"]["path"]
2817 .as_str()
2818 .map(str::to_owned)
2819 .or_else(|| {
2820 c["arguments"]["argv"].as_array().map(|a| {
2821 a.iter()
2822 .filter_map(Value::as_str)
2823 .collect::<Vec<_>>()
2824 .join(" ")
2825 })
2826 })
2827 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2828 },
2829 tools::policy::Object::describe,
2830 ),
2831 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2832 }
2833 })
2834 .collect()
2835}
2836
2837#[must_use]
2839pub fn last_denials(
2840 project_state: &Path,
2841 unit: &str,
2842) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2843 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2844 .ok()
2845 .and_then(|t| serde_json::from_str(&t).ok())
2846 .unwrap_or_default()
2847}
2848
2849pub(crate) fn record_denials(
2851 project_state: &Path,
2852 unit: &str,
2853 denials: &[speccoder_core::oracle::AutopilotDenial],
2854) {
2855 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2856 if denials.is_empty() {
2857 let _ = std::fs::remove_file(file);
2858 return;
2859 }
2860 let _ = write_atomic(
2861 &file,
2862 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2863 );
2864}
2865
2866#[cfg(test)]
2867mod tests {
2868 use super::*;
2869
2870 #[test]
2873 fn each_scenario_is_placed_in_a_free_file_of_its_own() {
2874 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2875 for (path, text) in [
2876 ("pytest.ini", "[pytest]\n"),
2877 (
2878 "tests/test_acceptance_us2.py",
2879 "def test_t014():\n pass\n",
2880 ),
2881 (
2882 "tests/test_acceptance_us2_1.py",
2883 "def test_t017():\n pass\n",
2884 ),
2885 ] {
2886 std::fs::create_dir_all(d.path().join(path).parent().unwrap()).unwrap();
2887 std::fs::write(d.path().join(path), text).unwrap();
2888 }
2889 let scenario = |id: &str| speccoder_core::spec::ScenarioRef {
2890 id: id.to_owned(),
2891 text: String::new(),
2892 };
2893 let req = OracleRequirement {
2894 key: "US2".to_owned(),
2895 title: "File input".to_owned(),
2896 text: String::new(),
2897 scenarios: vec![scenario("US2-1"), scenario("US2-2")],
2898 };
2899 let got: Vec<(String, String)> = places(d.path(), d.path(), &req)
2900 .into_iter()
2901 .map(|p| (p.scenario, p.path))
2902 .collect();
2903 assert_eq!(
2904 got,
2905 [
2906 (
2907 "US2-1".to_owned(),
2908 "tests/test_acceptance_us2_1_2.py".to_owned()
2909 ),
2910 (
2911 "US2-2".to_owned(),
2912 "tests/test_acceptance_us2_2.py".to_owned()
2913 ),
2914 ]
2915 );
2916 }
2917
2918 fn draft(content: &str) -> Draft {
2919 Draft {
2920 requirement: DraftRequirement {
2921 key: "US1".to_owned(),
2922 title: "Greet".to_owned(),
2923 text: "### User Story 1 - Greet".to_owned(),
2924 sha256: "sha256:w".to_owned(),
2925 },
2926 samples: 2,
2927 tests: vec![DraftTest {
2928 path: "tests/acceptance_us1.rs".to_owned(),
2929 sha256: digest_of(content),
2930 scenarios: vec!["US1-1".to_owned()],
2931 argv: vec!["cargo".to_owned(), "test".to_owned()],
2932 red_class: RedClass::Assertion,
2933 authored_by: AuthoredBy::Model,
2934 edited_by: None,
2935 }],
2936 discarded: Vec::new(),
2937 rejected: Vec::new(),
2938 expectations: Samples::default(),
2939 untestable: Samples::default(),
2940 ambiguities: Vec::new(),
2941 held_out: HeldOutSummary {
2942 state: "absent".to_owned(),
2943 count: 0,
2944 manifest_sha256: None,
2945 reason: None,
2946 discarded: Vec::new(),
2947 },
2948 sealed: None,
2949 notes: Vec::new(),
2950 runs: Vec::new(),
2951 context: Vec::new(),
2952 }
2953 }
2954
2955 #[test]
2956 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2957 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2958 let d = draft("#[test] fn t() {}");
2959 let files = vec![(
2960 "tests/acceptance_us1.rs".to_owned(),
2961 "#[test] fn t() {}".to_owned(),
2962 )];
2963 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2964 let (back, contents) = load(state.path(), "001-greet", "US1")
2965 .unwrap_or_else(|e| panic!("{e:?}"))
2966 .unwrap_or_else(|| panic!("stored"));
2967 assert_eq!(back, d);
2968 assert_eq!(contents, files);
2969 std::fs::write(
2970 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2971 "changed",
2972 )
2973 .unwrap_or_else(|e| panic!("{e}"));
2974 assert!(load(state.path(), "001-greet", "US1").is_err());
2975 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2976 assert!(
2977 load(state.path(), "001-greet", "US2")
2978 .unwrap_or_default()
2979 .is_none()
2980 );
2981 }
2982
2983 #[test]
2984 fn the_manifest_names_paths_contents_and_commands() {
2985 let argv = vec!["cargo".to_owned()];
2986 let other = vec!["make".to_owned()];
2987 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2988 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2989 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2990 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2991 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2992 assert_eq!(
2993 both,
2994 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2995 );
2996 let mut d = draft("x");
2997 assert!(d.manifest().is_some());
2998 d.tests[0].red_class = RedClass::Passed;
2999 assert!(d.manifest().is_none());
3000 assert_eq!(slug("cap: a / b"), "cap__a___b");
3001 }
3002
3003 #[test]
3004 fn a_lapse_is_noted_once() {
3005 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
3006 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
3007 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
3008 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
3009 }
3010}