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}
1003
1004impl<'a> Author<'a> {
1005 fn open(
1006 ctx: &'a Ctx,
1007 req: &OracleRequirement,
1008 provider: Option<Arc<dyn ModelProvider>>,
1009 options: &crate::RunOptions,
1010 ) -> Result<Self, Box<CommandResult>> {
1011 let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
1012 return Err(Box::new(refuse(
1013 CommandStatus::ConfigError,
1014 "CONFIG",
1015 "the test author needs a model ([model] in speccoder.toml)",
1016 )));
1017 };
1018 let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
1019 let plane = rt
1020 .block_on(async {
1021 substrate::Plane::open_with(
1022 &ctx.plane_dir,
1023 model,
1024 provider,
1025 None,
1026 substrate::Hooks {
1027 format: Some(ctx.format),
1028 progress: options.progress.clone(),
1029 ..substrate::Hooks::default()
1030 },
1031 )
1032 })
1033 .map_err(|e| failed(e.0))?;
1034 Ok(Self::with(ctx, req, Held::Own(rt), Held::Own(plane)))
1035 }
1036
1037 fn lent(
1039 ctx: &'a Ctx,
1040 req: &OracleRequirement,
1041 rt: &'a tokio::runtime::Runtime,
1042 plane: &'a substrate::Plane,
1043 ) -> Self {
1044 Self::with(ctx, req, Held::Lent(rt), Held::Lent(plane))
1045 }
1046
1047 fn with(
1048 ctx: &'a Ctx,
1049 req: &OracleRequirement,
1050 rt: Held<'a, tokio::runtime::Runtime>,
1051 plane: Held<'a, substrate::Plane>,
1052 ) -> Self {
1053 let unit_dir = ctx.root.join(&ctx.dir);
1054 let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
1055 let bundle = json!({
1056 "feature": ctx.feature,
1057 "constitution": constitution,
1058 "plan": plan,
1059 "story": req.text,
1060 "task": {
1061 "id": format!("req-{}", req.key),
1062 "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1063 "phase": "Review",
1064 "subsection": "",
1065 "story": req.key,
1066 "parallel": false,
1067 },
1068 "dependencies": [],
1069 });
1070 let instructions =
1071 crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1072 Self {
1073 ctx,
1074 rt,
1075 plane,
1076 bundle,
1077 instructions,
1078 test_layout: crate::layout::text_at(&ctx.root),
1079 }
1080 }
1081
1082 fn context(
1084 &self,
1085 req: &OracleRequirement,
1086 notes: &[String],
1087 ) -> speccoder_core::context::ContextManifest {
1088 let mut m = crate::context::Manifest::new();
1089 m.push(
1090 ContextKind::Story,
1091 "person",
1092 "/requirement",
1093 req.text.as_bytes(),
1094 );
1095 for s in &req.scenarios {
1096 m.push(
1097 ContextKind::Scenario,
1098 "person",
1099 &format!("/scenarios/{}", s.id),
1100 s.text.as_bytes(),
1101 );
1102 }
1103 m.push(
1104 ContextKind::Constitution,
1105 "worktree",
1106 "/bundle/constitution",
1107 self.bundle["constitution"]
1108 .as_str()
1109 .unwrap_or_default()
1110 .as_bytes(),
1111 );
1112 m.push(
1113 ContextKind::Plan,
1114 "worktree",
1115 "/bundle/plan",
1116 self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1117 );
1118 m.push(
1119 ContextKind::TestLayout,
1120 "worktree",
1121 "/test_layout",
1122 self.test_layout.as_bytes(),
1123 );
1124 for (i, n) in notes.iter().enumerate() {
1125 let kind = if n.starts_with("Rejected `") {
1126 ContextKind::Rejection
1127 } else if n.starts_with("Comment ") {
1128 ContextKind::Comment
1129 } else {
1130 ContextKind::Note
1131 };
1132 m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1133 }
1134 m.finish()
1135 }
1136
1137 #[allow(clippy::too_many_arguments)]
1139 fn call(
1140 &self,
1141 req: &OracleRequirement,
1142 files: &[String],
1143 manifest: &str,
1144 notes: &[String],
1145 again: Option<(&str, &str)>,
1146 sample: u8,
1147 ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1148 let context = self.context(req, notes);
1149 let suite = crate::pipeline::acceptance::suite(&self.ctx.root, &req.text);
1150 let interface = crate::pipeline::acceptance::interface(&self.ctx.root, &req.text);
1151 let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1152 bundle: self.bundle.clone(),
1153 requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1154 scenarios: &req.scenarios,
1155 files,
1156 manifest,
1157 notes,
1158 again,
1159 instructions: &self.instructions,
1160 context: &context,
1161 sample,
1162 test_layout: Some(&self.test_layout),
1163 suite: &suite,
1164 interface: &interface,
1165 });
1166 let task = format!("req-{}", req.key);
1167 let outcome = self
1168 .rt
1169 .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1170 .map_err(|e| format!("the test author failed: {}", e.0))?;
1171 let output = outcome
1172 .output
1173 .as_ref()
1174 .map(|o| o.peek().clone())
1175 .unwrap_or_default();
1176 let answer = output["answer"].clone();
1177 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1178 {
1179 let status = format!("{:?}", outcome.status);
1180 return Err(substrate::provider_refusal(&status).map_or_else(
1181 || {
1182 format!(
1183 "the test author did not answer: {}",
1184 substrate::why_not(&outcome.status)
1185 )
1186 },
1187 |r| format!("the model provider refused the request: {r}"),
1188 ));
1189 }
1190 Ok((answer, outcome.run_id.to_string(), context.entries))
1191 }
1192}
1193
1194fn expectations(
1200 answer: &Value,
1201 req: &OracleRequirement,
1202) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1203 let canonical = |id: &str| {
1204 req.scenarios
1205 .iter()
1206 .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1207 .map(|s| s.id.clone())
1208 .ok_or_else(|| {
1209 format!(
1210 "the test author's expectations name `{id}`, which {} does not have",
1211 req.key
1212 )
1213 })
1214 };
1215 let list = |field: &str| -> Result<Value, String> {
1216 match &answer[field] {
1217 Value::Null => Ok(Value::Array(Vec::new())),
1218 v @ Value::Array(_) => Ok(v.clone()),
1219 _ => Err(format!("the test author's `{field}` is not a list")),
1220 }
1221 };
1222 let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1223 .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1224 let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1225 .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1226 for x in &mut e {
1227 x.scenario = canonical(&x.scenario)?;
1228 }
1229 for x in &mut u {
1230 x.scenario = canonical(&x.scenario)?;
1231 }
1232 if e.is_empty() && u.is_empty() {
1233 return Err(
1234 "the test author stated no expectation for any scenario: the readings cannot be compared"
1235 .to_owned(),
1236 );
1237 }
1238 Ok((e, u))
1239}
1240
1241struct Bench {
1244 root: PathBuf,
1247 _scratch: Option<worktree::Scratch>,
1248 project: PathBuf,
1249 runner: sandbox::Runner,
1250 tmp: PathBuf,
1251}
1252
1253impl Bench {
1254 fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1255 let tmp = ctx.plane_dir.join("tmp");
1256 let scratch = worktree::scratch(&ctx.root, &tmp)
1257 .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1258 let project = project_in(&ctx.root, &scratch.path);
1259 let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1260 .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1261 if !runner.is_sandboxed() {
1264 return Err(Box::new(
1265 refuse(
1266 CommandStatus::ConfigError,
1267 "SANDBOX-UNAVAILABLE",
1268 &format!(
1269 "the red check runs model-written tests and needs the OS sandbox: {}",
1270 notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1271 ),
1272 )
1273 .with_fix(vec![
1274 "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1275 ]),
1276 ));
1277 }
1278 crate::include::copy(&scratch.path, &project, &ctx.root, &ctx.worktree.include).map_err(
1280 |e| {
1281 Box::new(refuse(
1282 CommandStatus::ConfigError,
1283 "PREPARE-FAILED",
1284 &format!("the files [worktree] include names could not be copied: {e}"),
1285 ))
1286 },
1287 )?;
1288 if ctx.worktree.prepare && crate::prepare::needed(&project) {
1289 crate::prepare::prepare(&crate::prepare::Prepare {
1290 worktree: &scratch.path,
1291 project: &project,
1292 config: &ctx.worktree,
1293 runner: &runner,
1294 state: Some(&ctx.state),
1295 })
1296 .map_err(|f| {
1297 Box::new(
1298 refuse(CommandStatus::ConfigError, "PREPARE-FAILED", &f.message)
1299 .with_fix(f.fix)
1300 .with_commands(f.commands),
1301 )
1302 })?;
1303 }
1304 Ok(Self {
1305 root: scratch.path.clone(),
1306 _scratch: Some(scratch),
1307 project,
1308 runner,
1309 tmp,
1310 })
1311 }
1312
1313 fn here(ctx: &Ctx, wt_root: &Path, runner: sandbox::Runner) -> Self {
1316 Self {
1317 root: wt_root.to_path_buf(),
1318 _scratch: None,
1319 project: ctx.root.clone(),
1320 runner,
1321 tmp: ctx.plane_dir.join("tmp"),
1322 }
1323 }
1324
1325 fn files(&self) -> (Vec<String>, String) {
1326 let files: Vec<String> = worktree::checkpoint(&self.root, &self.tmp)
1327 .and_then(|c| worktree::files(&self.root, &c))
1328 .unwrap_or_default()
1329 .into_iter()
1330 .take(400)
1331 .collect();
1332 let manifest = crate::pipeline::MANIFESTS
1333 .iter()
1334 .find_map(|m| {
1335 std::fs::read_to_string(self.project.join(m))
1336 .ok()
1337 .map(|t| format!("{m}:\n{t}"))
1338 })
1339 .map(|t| t.chars().take(8192).collect::<String>())
1340 .unwrap_or_default();
1341 (files, manifest)
1342 }
1343
1344 fn red(
1350 &self,
1351 tests: &[(String, String, Vec<String>)],
1352 ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1353 let mut out = Vec::new();
1354 for (path, content, argv) in tests {
1355 let full = self.project.join(path);
1356 let _ = std::fs::remove_file(&full);
1357 let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1358 let (bare, summary) = run();
1359 if classify_red(&bare, summary.as_ref()).is_red() {
1360 out.push(Err("red_without_it"));
1361 continue;
1362 }
1363 if let Some(parent) = full.parent() {
1364 std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1365 }
1366 std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1367 let (first, summary) = run();
1368 let class = classify_red(&first, summary.as_ref());
1369 let class = if !class.is_red() {
1370 class
1371 } else {
1372 let (second, summary) = run();
1373 if classify_red(&second, summary.as_ref()).is_red() {
1374 class
1375 } else {
1376 RedClass::Flaky
1377 }
1378 };
1379 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1380 out.push(Ok(class));
1381 }
1382 Ok(out)
1383 }
1384}
1385
1386fn keep(
1388 ctx: &Ctx,
1389 bench: &Bench,
1390 answer: &Value,
1391 req: &OracleRequirement,
1392 taken: &[(String, Vec<String>)],
1393) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1394 let (valid, refused) =
1395 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios);
1396 let mut discarded: Vec<OracleDiscarded> = refused
1397 .into_iter()
1398 .map(|r| OracleDiscarded {
1399 path: r.path,
1400 why: r.why.to_owned(),
1401 })
1402 .collect();
1403 let set_argv: Vec<String> = crate::pipeline::acceptance::argv_of(&answer["argv"]);
1404 let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1405 let mut kept = Vec::new();
1406 for f in valid {
1407 let mut discard = |why: &str| {
1408 discarded.push(OracleDiscarded {
1409 path: f.path.clone(),
1410 why: why.to_owned(),
1411 });
1412 };
1413 if f.cited.is_empty() {
1414 discard("cites_no_scenario");
1415 continue;
1416 }
1417 if taken.iter().any(|(p, _)| *p == f.path)
1418 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1419 {
1420 discard("exists");
1421 continue;
1422 }
1423 let argv = if f.argv.is_empty() {
1424 set_argv.clone()
1425 } else {
1426 f.argv.clone()
1427 };
1428 if taken.iter().any(|(_, a)| *a == argv)
1431 || kept
1432 .iter()
1433 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1434 {
1435 discard("command_shared");
1436 continue;
1437 }
1438 if argv.is_empty()
1439 || !crate::pipeline::test_command_allowed(
1440 &ctx.policy,
1441 Some(&layout),
1442 &argv,
1443 &[f.path.as_str()],
1444 )
1445 {
1446 discard("command_not_allowed");
1447 continue;
1448 }
1449 if kept.len() + taken.len() >= LIMIT {
1450 discard("limit");
1451 continue;
1452 }
1453 kept.push((f.path, f.content, f.cited, argv));
1454 }
1455 (kept, discarded)
1456}
1457
1458fn write(
1461 ctx: &Ctx,
1462 req: &OracleRequirement,
1463 provider: Option<Arc<dyn ModelProvider>>,
1464 options: &crate::RunOptions,
1465) -> Outcome {
1466 let samples = ctx.config.oracle_samples.clamp(1, 2);
1467 let author = Author::open(ctx, req, provider, options)?;
1468 let bench = Bench::open(ctx)?;
1469 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1472 Box::new(
1473 refuse(
1474 CommandStatus::NeedsHuman,
1475 "ORACLE-TAMPERED",
1476 &format!(
1477 "{}: writing anew would lose the rejections and notes it holds",
1478 t.0
1479 ),
1480 )
1481 .with_fix(vec![format!(
1482 "→ restore it, or remove {} to start {}'s tests over",
1483 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1484 req.key
1485 )]),
1486 )
1487 })?;
1488 let (rejected, mut notes) = earlier
1489 .as_ref()
1490 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1491 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1492 .unwrap_or_default();
1493 notes.dedup();
1494 let told: Vec<String> = rejected
1495 .iter()
1496 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1497 .chain(notes.iter().cloned())
1498 .collect();
1499 let (draft, contents) = draft(
1500 ctx, req, &author, &bench, samples, &told, rejected, notes, true, options,
1501 )?;
1502 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1503 let red = draft.red().count();
1504 let open = draft.open_ambiguities().count();
1505 Ok(format!(
1506 "wrote {} test(s) for {}: {red} red{}{}",
1507 draft.tests.len(),
1508 req.key,
1509 if draft.held_out.state == "kept" {
1510 format!(", {} held out", draft.held_out.count)
1511 } else {
1512 String::new()
1513 },
1514 if open > 0 {
1515 format!(
1516 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1517 req.key, req.key
1518 )
1519 } else {
1520 String::new()
1521 }
1522 ))
1523}
1524
1525#[allow(clippy::too_many_arguments)]
1528fn draft(
1529 ctx: &Ctx,
1530 req: &OracleRequirement,
1531 author: &Author<'_>,
1532 bench: &Bench,
1533 samples: u8,
1534 told: &[String],
1535 rejected: Vec<OracleRejected>,
1536 notes: Vec<String>,
1537 with_held_out: bool,
1538 options: &crate::RunOptions,
1539) -> Result<Loaded, Box<CommandResult>> {
1540 let (files, manifest) = bench.files();
1541 let of = u32::from(samples);
1542 stage(options, &req.key, "writing", 1, of);
1543 let (first, run1, mut context) = author
1544 .call(req, &files, &manifest, told, None, 1)
1545 .map_err(failed)?;
1546 let read = |answer: &Value| {
1549 let got = expectations(answer, req);
1550 if samples >= 2 {
1551 got.map_err(failed)
1552 } else {
1553 Ok(got.unwrap_or_default())
1554 }
1555 };
1556 let (e1, u1) = read(&first)?;
1557 let mut runs = vec![run1];
1558 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1559 if samples >= 2 {
1560 stage(options, &req.key, "writing", 2, of);
1561 let (second, run2, more) = author
1562 .call(req, &files, &manifest, told, None, 2)
1563 .map_err(failed)?;
1564 (e2, u2) = read(&second)?;
1565 runs.push(run2);
1566 for c in more {
1567 if !context.contains(&c) {
1568 context.push(c);
1569 }
1570 }
1571 stage(options, &req.key, "comparing", 1, 1);
1572 }
1573 let ambiguities = if samples >= 2 {
1574 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1575 } else {
1576 Vec::new()
1577 };
1578 let (kept, mut discarded) = keep(ctx, bench, &first, req, &[]);
1579 stage(options, &req.key, "checking_red", 1, 1);
1580 let plan: Vec<(String, String, Vec<String>)> = kept
1581 .iter()
1582 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1583 .collect();
1584 let classes = bench.red(&plan).map_err(failed)?;
1585 let mut tests: Vec<DraftTest> = Vec::new();
1586 let mut contents: Vec<(String, String)> = Vec::new();
1587 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1588 match class {
1589 Ok(red_class) => {
1590 tests.push(DraftTest {
1591 path: path.clone(),
1592 sha256: digest_of(&content),
1593 scenarios: cited,
1594 argv,
1595 red_class,
1596 authored_by: AuthoredBy::Model,
1597 edited_by: None,
1598 });
1599 contents.push((path, content));
1600 }
1601 Err(why) => discarded.push(OracleDiscarded {
1602 path,
1603 why: why.to_owned(),
1604 }),
1605 }
1606 }
1607 let (held_out, sealed) = if with_held_out {
1609 held_out(ctx, author, bench, req, &tests, options)
1610 } else {
1611 (
1612 HeldOutSummary {
1613 state: "off".to_owned(),
1614 count: 0,
1615 manifest_sha256: None,
1616 reason: None,
1617 discarded: Vec::new(),
1618 },
1619 None,
1620 )
1621 };
1622 let draft = Draft {
1623 requirement: DraftRequirement {
1624 key: req.key.clone(),
1625 title: req.title.clone(),
1626 text: req.text.clone(),
1627 sha256: req.digest(),
1628 },
1629 samples,
1630 tests,
1631 discarded,
1632 rejected,
1633 expectations: Samples {
1634 sample1: e1,
1635 sample2: e2,
1636 },
1637 untestable: Samples {
1638 sample1: u1,
1639 sample2: u2,
1640 },
1641 ambiguities,
1642 held_out,
1643 sealed,
1644 notes,
1645 runs,
1646 context,
1647 };
1648 Ok((draft, contents))
1649}
1650
1651fn held_out(
1655 ctx: &Ctx,
1656 author: &Author<'_>,
1657 bench: &Bench,
1658 req: &OracleRequirement,
1659 tests: &[DraftTest],
1660 options: &crate::RunOptions,
1661) -> (HeldOutSummary, Option<SealedRef>) {
1662 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1663 state: state.to_owned(),
1664 count: 0,
1665 manifest_sha256: None,
1666 reason: reason.map(str::to_owned),
1667 discarded: Vec::new(),
1668 };
1669 if !ctx.config.held_out {
1670 return (summary("off", None), None);
1671 }
1672 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1673 if red.is_empty() {
1674 return (summary("absent", Some("not_red")), None);
1675 }
1676 stage(options, &req.key, "held_out", 1, 1);
1677 let visible = crate::pipeline::PreparedAcceptance {
1678 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1679 argv: red.first().map(|t| t.argv.clone()),
1680 more_argv: Vec::new(),
1681 red: true,
1682 red_class: Some(RedClass::Assertion),
1683 held_out: None,
1684 generation_failed: None,
1685 skip_cause: None,
1686 };
1687 let task = format!("req-{}", req.key);
1688 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1689 let (report, sha) = crate::pipeline::prepare_held_out(
1690 &crate::pipeline::AcceptanceSetup {
1691 rt: &author.rt,
1692 plane: &author.plane,
1693 project: &bench.project,
1694 worktree: &bench.root,
1695 tmp: &bench.tmp,
1696 policy: &ctx.policy,
1697 feature: &ctx.feature,
1698 task: &task,
1699 bundle: author.bundle.clone(),
1700 scenarios: &req.scenarios,
1701 instructions: &author.instructions,
1702 runner: &bench.runner,
1703 layout: None,
1704 told: &[],
1705 },
1706 &visible,
1707 &req.scenarios,
1708 &store,
1709 false,
1710 );
1711 match sha {
1712 Some(sha) => {
1713 let count = report["kept"]
1714 .as_u64()
1715 .and_then(|n| usize::try_from(n).ok())
1716 .unwrap_or(0);
1717 (
1718 HeldOutSummary {
1719 state: "kept".to_owned(),
1720 count,
1721 manifest_sha256: Some(format!("sha256:{sha}")),
1722 reason: None,
1723 discarded: Vec::new(),
1724 },
1725 Some(SealedRef {
1726 key: req.key.clone(),
1727 manifest_sha256: sha,
1728 count,
1729 }),
1730 )
1731 }
1732 None => (
1733 HeldOutSummary {
1734 discarded: report["discarded"]
1735 .as_array()
1736 .into_iter()
1737 .flatten()
1738 .filter_map(|d| d["reason"].as_str().map(str::to_owned))
1739 .collect(),
1740 ..summary(
1741 "absent",
1742 Some(report["reason"].as_str().unwrap_or("none_kept")),
1743 )
1744 },
1745 None,
1746 ),
1747 }
1748}
1749
1750fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1751 let Some(path) = r.path else {
1752 return Err(usage("name the test to reject (--reject PATH)"));
1753 };
1754 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1755 return Err(usage(
1756 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1757 ));
1758 };
1759 let (mut d, contents) = existing(ctx, req)?;
1760 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1761 return Err(usage(&format!("{} has no test {path}", req.key)));
1762 };
1763 d.tests.remove(at);
1764 d.rejected.push(OracleRejected {
1765 path: path.to_owned(),
1766 reason: reason.to_owned(),
1767 by: by.to_owned(),
1768 at: now(),
1769 });
1770 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1771 Ok(format!(
1772 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1773 req.key, req.key
1774 ))
1775}
1776
1777fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1778 let Some(path) = r.path else {
1779 return Err(usage("name the test to edit (--edit PATH)"));
1780 };
1781 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1782 return Err(usage("an edit needs the test's new content (--from FILE)"));
1783 };
1784 let (mut d, mut contents) = existing(ctx, req)?;
1785 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1786 return Err(usage(&format!("{} has no test {path}", req.key)));
1787 };
1788 let bench = Bench::open(ctx)?;
1789 let plan = [(
1790 path.to_owned(),
1791 content.to_owned(),
1792 d.tests[at].argv.clone(),
1793 )];
1794 let checked = bench
1795 .red(&plan)
1796 .map_err(failed)?
1797 .pop()
1798 .unwrap_or(Ok(RedClass::Unclassified));
1799 let class = checked.unwrap_or(RedClass::Unclassified);
1801 let t = &mut d.tests[at];
1802 t.sha256 = digest_of(content);
1803 t.red_class = class;
1804 t.authored_by = AuthoredBy::Person;
1805 t.edited_by = Some(by.to_owned());
1806 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1807 c.1 = content.to_owned();
1808 }
1809 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1810 Ok(format!(
1811 "edited {path} of {}: {}",
1812 req.key,
1813 if class.is_red() {
1814 format!("red ({})", crate::pipeline::class_word(class))
1815 } else if checked.is_err() {
1816 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1817 } else {
1818 format!(
1819 "not red ({}) — no evidence, it cannot be frozen",
1820 crate::pipeline::class_word(class)
1821 )
1822 }
1823 ))
1824}
1825
1826#[allow(clippy::too_many_arguments)]
1829fn rewrite(
1830 ctx: &Ctx,
1831 req: &OracleRequirement,
1832 mut d: Draft,
1833 mut contents: Vec<(String, String)>,
1834 replaced: &[String],
1835 note: String,
1836 again: Option<(&str, &str)>,
1837 only: Option<&str>,
1838 provider: Option<Arc<dyn ModelProvider>>,
1839 options: &crate::RunOptions,
1840) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1841 let author = Author::open(ctx, req, provider, options)?;
1842 let bench = Bench::open(ctx)?;
1843 let (files, manifest) = bench.files();
1844 d.notes.push(note);
1845 let told = d.all_notes();
1846 stage(options, &req.key, "writing", 1, 1);
1847 let (answer, run, context) = author
1848 .call(req, &files, &manifest, &told, again, 1)
1849 .map_err(failed)?;
1850 d.runs.push(run);
1851 for c in context {
1852 if !d.context.contains(&c) {
1853 d.context.push(c);
1854 }
1855 }
1856 let keep_paths: Vec<(String, Vec<String>)> = d
1857 .tests
1858 .iter()
1859 .filter(|t| !replaced.contains(&t.path))
1860 .map(|t| (t.path.clone(), t.argv.clone()))
1861 .collect();
1862 let mut answer = answer;
1864 if let Some(files) = answer["files"].as_array_mut() {
1865 files.retain(|f| {
1866 only.is_none_or(|s| {
1867 f["scenarios"]
1868 .as_array()
1869 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1870 })
1871 });
1872 }
1873 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1874 if new.is_empty() {
1875 return Err(failed(format!(
1876 "the test author wrote no usable test{}",
1877 discarded
1878 .first()
1879 .map(|x| format!(" ({} {})", x.path, x.why))
1880 .unwrap_or_default()
1881 )));
1882 }
1883 d.tests.retain(|t| !replaced.contains(&t.path));
1884 contents.retain(|(p, _)| !replaced.contains(p));
1885 d.discarded.extend(discarded);
1886 stage(options, &req.key, "checking_red", 1, 1);
1887 let plan: Vec<(String, String, Vec<String>)> = new
1889 .iter()
1890 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1891 .collect();
1892 let classes = bench.red(&plan).map_err(failed)?;
1893 let mut written = Vec::new();
1894 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1895 let red_class = match class {
1896 Ok(c) => c,
1897 Err(why) => {
1898 d.discarded.push(OracleDiscarded {
1899 path,
1900 why: why.to_owned(),
1901 });
1902 continue;
1903 }
1904 };
1905 d.tests.push(DraftTest {
1906 sha256: digest_of(&content),
1907 scenarios: cited,
1908 argv,
1909 red_class,
1910 authored_by: AuthoredBy::Model,
1911 edited_by: None,
1912 path: path.clone(),
1913 });
1914 contents.push((path.clone(), content));
1915 written.push(path);
1916 }
1917 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1918 Ok((d, written))
1919}
1920
1921fn again(
1922 ctx: &Ctx,
1923 req: &OracleRequirement,
1924 r: &Request<'_>,
1925 _by: &str,
1926 provider: Option<Arc<dyn ModelProvider>>,
1927 options: &crate::RunOptions,
1928) -> Outcome {
1929 let Some(path) = r.path else {
1930 return Err(usage("name the test to write again (--again PATH)"));
1931 };
1932 let comment = r
1933 .text
1934 .map(str::trim)
1935 .filter(|t| !t.is_empty())
1936 .unwrap_or("write it again");
1937 let (d, contents) = existing(ctx, req)?;
1938 let Some(content) = contents
1939 .iter()
1940 .find(|(p, _)| p == path)
1941 .map(|(_, c)| c.clone())
1942 else {
1943 return Err(usage(&format!("{} has no test {path}", req.key)));
1944 };
1945 let (d, written) = rewrite(
1946 ctx,
1947 req,
1948 d,
1949 contents,
1950 &[path.to_owned()],
1951 format!("Comment on `{path}`: {comment}"),
1952 Some((path, &content)),
1953 None,
1954 provider,
1955 options,
1956 )?;
1957 let red = d
1958 .tests
1959 .iter()
1960 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
1961 .count();
1962 Ok(format!(
1963 "wrote {path} of {} again: {}",
1964 req.key,
1965 if written.is_empty() {
1966 "nothing kept — its command is red without it, so it decides nothing".to_owned()
1967 } else if red == written.len() {
1968 "red".to_owned()
1969 } else {
1970 "not red — no evidence, it cannot be frozen".to_owned()
1971 }
1972 ))
1973}
1974
1975fn reading(
1976 ctx: &Ctx,
1977 req: &OracleRequirement,
1978 r: &Request<'_>,
1979 by: &str,
1980 provider: Option<Arc<dyn ModelProvider>>,
1981 options: &crate::RunOptions,
1982) -> Outcome {
1983 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
1984 return Err(usage(
1985 "name the reading and the scenario (--reading A|B US1-2)",
1986 ));
1987 };
1988 let (mut d, contents) = existing(ctx, req)?;
1989 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
1990 return Err(usage(&format!(
1991 "{} has no ambiguity on {scenario}",
1992 req.key
1993 )));
1994 };
1995 item.resolution = Some(Resolution {
1996 reading: choice,
1997 by: by.to_owned(),
1998 at: now(),
1999 });
2000 let meant = if choice == Reading::A {
2001 item.a.clone()
2002 } else {
2003 item.b.clone()
2004 };
2005 if choice == Reading::A {
2006 d.notes.push(format!(
2007 "For {scenario} the person means reading A: given {}, expect {}",
2008 meant.given, meant.expect
2009 ));
2010 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
2011 return Ok(format!(
2012 "{scenario} of {}: reading A is meant — the tests stay",
2013 req.key
2014 ));
2015 }
2016 let replaced: Vec<String> = d
2017 .tests
2018 .iter()
2019 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
2020 .map(|t| t.path.clone())
2021 .collect();
2022 let (_, written) = rewrite(
2023 ctx,
2024 req,
2025 d,
2026 contents,
2027 &replaced,
2028 format!(
2029 "For {scenario} the person means reading B: given {}, expect {}",
2030 meant.given, meant.expect
2031 ),
2032 None,
2033 Some(scenario),
2034 provider,
2035 options,
2036 )?;
2037 Ok(format!(
2038 "{scenario} of {}: reading B is meant — wrote {} anew",
2039 req.key,
2040 written.join(", ")
2041 ))
2042}
2043
2044#[derive(Debug, Clone)]
2047pub struct Freezable {
2048 pub req: OracleRequirement,
2050 pub draft: Draft,
2052 pub contents: Vec<(String, String)>,
2054 pub manifest: String,
2056}
2057
2058pub fn freezable(
2061 project_state: &Path,
2062 unit: &str,
2063 requirements: &[OracleRequirement],
2064) -> Result<Vec<Freezable>, Tampered> {
2065 let mut out = Vec::new();
2066 for r in requirements {
2067 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
2068 continue;
2069 };
2070 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
2071 continue;
2072 }
2073 let Some(manifest) = draft.manifest() else {
2074 continue;
2075 };
2076 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
2077 out.push(Freezable {
2078 req: r.clone(),
2079 contents: contents
2080 .into_iter()
2081 .filter(|(p, _)| red.contains(p))
2082 .collect(),
2083 draft,
2084 manifest,
2085 });
2086 }
2087 Ok(out)
2088}
2089
2090pub fn freeze(
2094 project_state: &Path,
2095 unit: &str,
2096 f: &Freezable,
2097 approval_digest: &str,
2098 decided: Decided,
2099 producer: Producer,
2100) -> Result<PreFrozenAcceptance, String> {
2101 let red: Vec<&DraftTest> = f.draft.red().collect();
2102 freeze_tests(
2103 project_state,
2104 unit,
2105 f,
2106 &red,
2107 approval_digest,
2108 decided,
2109 producer,
2110 )
2111}
2112
2113fn freeze_tests(
2115 project_state: &Path,
2116 unit: &str,
2117 f: &Freezable,
2118 red: &[&DraftTest],
2119 approval_digest: &str,
2120 decided: Decided,
2121 producer: Producer,
2122) -> Result<PreFrozenAcceptance, String> {
2123 let sealed = match &f.draft.sealed {
2124 Some(s) => {
2125 crate::held_out::open_sealed(project_state, unit, s)
2126 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
2127 Some(s.clone())
2128 }
2129 None => None,
2130 };
2131 let record = PreFrozenAcceptance {
2132 unit: unit.to_owned(),
2133 requirement: f.req.key.clone(),
2134 wording_digest: f.draft.requirement.sha256.clone(),
2135 approval_digest: approval_digest.to_owned(),
2136 visible: red
2137 .iter()
2138 .map(|t| VisibleTest {
2139 path: t.path.clone(),
2140 sha256: t.sha256.clone(),
2141 cites: t.scenarios.clone(),
2142 })
2143 .collect(),
2144 held_out_sealed: sealed,
2145 producer,
2146 decided,
2147 };
2148 crate::pre_frozen::store(project_state, &record, &f.contents)?;
2149 let frozen = Frozen {
2150 requirement_sha256: f.draft.requirement.sha256.clone(),
2151 wording: format!(
2152 "{}\n{}",
2153 f.draft.requirement.title, f.draft.requirement.text
2154 ),
2155 manifest_sha256: f.manifest.clone(),
2156 tests: red
2157 .iter()
2158 .map(|t| FrozenTest {
2159 path: t.path.clone(),
2160 sha256: t.sha256.clone(),
2161 argv: t.argv.clone(),
2162 scenarios: t.scenarios.clone(),
2163 })
2164 .collect(),
2165 };
2166 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2167 write_atomic(
2168 &dir(project_state, unit, &f.req.key).join("frozen.json"),
2169 &text,
2170 )?;
2171 Ok(record)
2172}
2173
2174fn not_kept(t: &DraftTest, many: bool) -> Option<String> {
2181 if many && t.scenarios.len() > 1 {
2182 return Some(format!(
2183 "it tests {} together — one file per scenario, so each turns green on its own",
2184 t.scenarios.join(", ")
2185 ));
2186 }
2187 (!usable(t)).then(|| format!("it did not fail on an assertion ({:?})", t.red_class))
2188}
2189
2190fn usable(t: &DraftTest) -> bool {
2191 match t.red_class {
2192 RedClass::Assertion | RedClass::Passed => true,
2193 RedClass::Absent => [".rs", ".go", ".java", ".kt", ".cs", ".swift"]
2194 .iter()
2195 .any(|e| t.path.ends_with(e)),
2196 _ => false,
2197 }
2198}
2199
2200#[derive(Debug)]
2204pub(crate) struct RunOracle {
2205 pub tests: Vec<FrozenTest>,
2208 pub contents: Vec<(String, String)>,
2210 pub contracts: Vec<Expectation>,
2212 pub untestable: Vec<Untestable>,
2214}
2215
2216#[allow(clippy::too_many_arguments)]
2223pub(crate) fn write_for_run(
2224 project: &Path,
2225 wt_root: &Path,
2226 feature: &str,
2227 state: &Path,
2228 story: &str,
2229 runner: sandbox::Runner,
2230 (rt, plane): (&tokio::runtime::Runtime, &substrate::Plane),
2231 (only, told): (&[String], &[String]),
2232 run: &str,
2233 options: &crate::RunOptions,
2234) -> Result<RunOracle, Box<CommandResult>> {
2235 let ctx = open(project, Some(feature), state)?;
2236 let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
2237 let whole = (story == ctx.feature && !requirements.iter().any(|r| r.key == story)).then(|| {
2239 OracleRequirement {
2240 key: story.to_owned(),
2241 title: ctx.feature.clone(),
2242 text: requirements
2243 .iter()
2244 .map(|r| format!("{}: {}\n{}", r.key, r.title, r.text))
2245 .collect::<Vec<_>>()
2246 .join("\n\n"),
2247 scenarios: requirements
2248 .iter()
2249 .flat_map(|r| r.scenarios.clone())
2250 .collect(),
2251 }
2252 });
2253 let Some(mut req) = whole.or_else(|| requirements.into_iter().find(|r| r.key == story)) else {
2254 return Err(Box::new(refuse(
2255 CommandStatus::NeedsHuman,
2256 "ORACLE-UNWRITTEN",
2257 &format!("{story} has no scenarios to write tests for"),
2258 )));
2259 };
2260 if !only.is_empty() {
2262 req.scenarios.retain(|s| only.contains(&s.id));
2263 }
2264 let _lock = UnitLock::take(&ctx.plane_dir, &ctx.feature)
2265 .map_err(|e| Box::new(refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e)))?;
2266 let author = Author::lent(&ctx, &req, rt, plane);
2267 let many = req.scenarios.len() > 1;
2268 let bench = Bench::here(&ctx, wt_root, runner);
2269 let kept = |d: &Draft| -> Vec<String> {
2270 let mut covered: Vec<String> = d
2271 .tests
2272 .iter()
2273 .filter(|t| not_kept(t, many).is_none())
2274 .flat_map(|t| t.scenarios.iter().cloned())
2275 .collect();
2276 covered.sort();
2277 covered.dedup();
2278 covered
2279 };
2280 let (mut draft, mut contents) = draft(
2281 &ctx,
2282 &req,
2283 &author,
2284 &bench,
2285 1,
2286 told,
2287 Vec::new(),
2288 told.to_vec(),
2289 only.is_empty(),
2290 options,
2291 )?;
2292 let missing = |d: &Draft| -> Vec<String> {
2293 let covered = kept(d);
2294 req.scenarios
2295 .iter()
2296 .map(|s| s.id.clone())
2297 .filter(|id| !covered.contains(id))
2298 .collect()
2299 };
2300 let untestable: Vec<String> = draft
2304 .untestable
2305 .sample1
2306 .iter()
2307 .map(|u| u.scenario.clone())
2308 .collect();
2309 let broke =
2310 !draft.discarded.is_empty() || draft.tests.iter().any(|t| not_kept(t, many).is_some());
2311 let left: Vec<String> = missing(&draft)
2312 .into_iter()
2313 .filter(|id| !untestable.contains(id))
2314 .collect();
2315 if broke && !left.is_empty() {
2316 let told: Vec<String> = draft
2317 .discarded
2318 .iter()
2319 .map(|d| format!("Not kept `{}`: {}", d.path, d.why))
2320 .chain(draft.tests.iter().filter_map(|t| {
2321 not_kept(t, many).map(|why| format!("Not kept `{}`: {why}", t.path))
2322 }))
2323 .chain(std::iter::once(format!(
2324 "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",
2325 left.join(", ")
2326 )))
2327 .collect();
2328 let (second, more) = self::draft(
2329 &ctx,
2330 &req,
2331 &author,
2332 &bench,
2333 1,
2334 &told,
2335 Vec::new(),
2336 told.clone(),
2337 only.is_empty(),
2338 options,
2339 )?;
2340 if missing(&second).len() < left.len() {
2341 (draft, contents) = (second, more);
2342 }
2343 }
2344 if only.is_empty() {
2346 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
2347 }
2348 let keep: Vec<&DraftTest> = draft
2349 .tests
2350 .iter()
2351 .filter(|t| not_kept(t, many).is_none())
2352 .collect();
2353 if keep.is_empty() && draft.tests.is_empty() && draft.discarded.is_empty() {
2356 return Ok(RunOracle {
2357 tests: Vec::new(),
2358 contents: Vec::new(),
2359 contracts: draft.expectations.sample1.clone(),
2360 untestable: draft.untestable.sample1.clone(),
2361 });
2362 }
2363 if keep.is_empty() {
2364 return Err(Box::new(refuse(
2365 CommandStatus::NeedsHuman,
2366 "ORACLE-UNWRITTEN",
2367 &format!(
2368 "no test written for {story} could be used ({} not kept)",
2369 draft.tests.len() + draft.discarded.len()
2370 ),
2371 )));
2372 }
2373 let manifest = manifest_digest(
2374 keep.iter()
2375 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2376 );
2377 let paths: Vec<&str> = keep.iter().map(|t| t.path.as_str()).collect();
2378 let kept_contents: Vec<(String, String)> = contents
2379 .iter()
2380 .filter(|(p, _)| paths.contains(&p.as_str()))
2381 .cloned()
2382 .collect();
2383 let f = Freezable {
2384 req: req.clone(),
2385 draft: draft.clone(),
2386 contents: kept_contents.clone(),
2387 manifest,
2388 };
2389 if only.is_empty() {
2392 freeze_tests(
2393 &ctx.plane_dir,
2394 &ctx.feature,
2395 &f,
2396 &keep,
2397 &format!(
2398 "run:{run}{}",
2399 if only.is_empty() { "" } else { ":amendment" }
2400 ),
2401 Decided {
2402 by: "speccoder run".to_owned(),
2403 via: Via::Terminal,
2404 at: now(),
2405 },
2406 Producer::Run,
2407 )
2408 .map_err(failed)?;
2409 }
2410 Ok(RunOracle {
2411 tests: keep
2412 .iter()
2413 .map(|t| FrozenTest {
2414 path: t.path.clone(),
2415 sha256: t.sha256.clone(),
2416 argv: t.argv.clone(),
2417 scenarios: t.scenarios.clone(),
2418 })
2419 .collect(),
2420 contents: kept_contents,
2421 contracts: draft.expectations.sample1.clone(),
2422 untestable: draft.untestable.sample1.clone(),
2423 })
2424}
2425
2426pub(crate) fn refreeze(
2433 project_state: &Path,
2434 unit: &str,
2435 key: &str,
2436 tests: Vec<FrozenTest>,
2437) -> Result<(), String> {
2438 let mut frozen = load_frozen(project_state, unit, key)
2439 .ok_or_else(|| format!("{key} has no frozen oracle here"))?;
2440 frozen.manifest_sha256 = manifest_digest(
2441 tests
2442 .iter()
2443 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2444 );
2445 frozen.tests = tests;
2446 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2447 write_atomic(&dir(project_state, unit, key).join("frozen.json"), &text)
2448}
2449
2450#[derive(Debug)]
2452pub(crate) enum TaskReuse {
2453 Ready {
2455 record: Box<PreFrozenAcceptance>,
2457 files: Vec<(String, String)>,
2459 tests: Vec<crate::pipeline::ReusedTest>,
2461 manifest: String,
2463 },
2464 Reused {
2466 req: String,
2468 },
2469 Elsewhere {
2471 req: String,
2473 },
2474 Lapsed {
2476 req: String,
2478 frozen: String,
2480 current: String,
2482 },
2483 Tampered(String),
2485 Unreadable(String),
2487}
2488
2489pub(crate) fn for_task(
2494 project_state: &Path,
2495 project: &Path,
2496 wt_root: &Path,
2497 format: &dyn Adapter,
2498 snapshot: &ProjectSnapshot,
2499 scenarios: &[speccoder_core::spec::ScenarioRef],
2500) -> Option<TaskReuse> {
2501 let f = snapshot.feature.as_ref()?;
2502 let unit = f.feature.name.as_str();
2503 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2504 Ok(x) => x,
2505 Err(e) => return Some(TaskReuse::Unreadable(e)),
2507 };
2508 if entries.is_empty() {
2509 return None;
2510 }
2511 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2512 let requirements = format.oracle_requirements(snapshot);
2513 let (req, entry) = requirements.iter().find_map(|r| {
2514 let e = entries.get(&r.key)?;
2515 r.scenarios
2516 .iter()
2517 .any(|s| ids.contains(&s.id.as_str()))
2518 .then_some((r, e))
2519 })?;
2520 let digest = req.digest();
2521 if entry.requirement_sha256 != digest {
2522 return Some(TaskReuse::Lapsed {
2523 req: req.key.clone(),
2524 frozen: entry.requirement_sha256.clone(),
2525 current: digest,
2526 });
2527 }
2528 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2529 Ok(Some(x)) => x,
2530 Ok(None) => {
2531 return Some(TaskReuse::Elsewhere {
2532 req: req.key.clone(),
2533 });
2534 }
2535 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2536 };
2537 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2541 let reused = crate::git::Git::at(wt_root)
2542 .run(&[
2543 "log",
2544 "--format=%H",
2545 "--fixed-strings",
2546 &format!("--grep={needle}"),
2547 "HEAD",
2548 ])
2549 .map(|out| !out.is_empty());
2550 match reused {
2551 Ok(true) => {
2552 return Some(TaskReuse::Reused {
2553 req: req.key.clone(),
2554 });
2555 }
2556 Ok(false) => {}
2557 Err(e) => {
2558 return Some(TaskReuse::Unreadable(format!(
2559 "the worktree's history cannot be read: {}",
2560 e.0
2561 )));
2562 }
2563 }
2564 if !record.is_current(&digest) {
2565 return Some(TaskReuse::Lapsed {
2566 req: req.key.clone(),
2567 frozen: record.wording_digest.clone(),
2568 current: digest,
2569 });
2570 }
2571 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2572 return Some(TaskReuse::Tampered(format!(
2573 "{}'s frozen commands are missing from the state directory",
2574 req.key
2575 )));
2576 };
2577 let manifest = manifest_digest(
2578 frozen
2579 .tests
2580 .iter()
2581 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2582 );
2583 let same_files = frozen.tests.len() == record.visible.len()
2584 && frozen.tests.iter().all(|t| {
2585 record
2586 .visible
2587 .iter()
2588 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2589 });
2590 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2591 return Some(TaskReuse::Tampered(format!(
2592 "{}'s frozen tests no longer match the manifest review.json names",
2593 req.key
2594 )));
2595 }
2596 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2599 return Some(TaskReuse::Tampered(format!(
2600 "{}'s frozen set names another approval than review.json{}",
2601 req.key,
2602 if approval.is_none() {
2603 " (which records none)"
2604 } else {
2605 ""
2606 }
2607 )));
2608 }
2609 let sealed = record
2610 .held_out_sealed
2611 .as_ref()
2612 .map(|h| format!("sha256:{}", h.manifest_sha256));
2613 if sealed != entry.held_out_sha256 {
2614 return Some(TaskReuse::Tampered(format!(
2615 "{}'s frozen held-out set is not the one review.json names",
2616 req.key
2617 )));
2618 }
2619 let tests = frozen
2620 .tests
2621 .iter()
2622 .map(|t| crate::pipeline::ReusedTest {
2623 path: t.path.clone(),
2624 cites: t.scenarios.clone(),
2625 argv: t.argv.clone(),
2626 green: false,
2627 })
2628 .collect();
2629 Some(TaskReuse::Ready {
2630 record: Box::new(record),
2631 files,
2632 tests,
2633 manifest,
2634 })
2635}
2636
2637pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2640 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2641 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2642 .ok()
2643 .and_then(|t| serde_json::from_str(&t).ok())
2644 .unwrap_or_default();
2645 let key = format!("{req}={frozen}");
2646 if seen.contains(&key) {
2647 return false;
2648 }
2649 seen.push(key);
2650 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2651 true
2652}
2653
2654#[must_use]
2656pub fn autopilot_denials(
2657 task: &str,
2658 tool_calls: &[Value],
2659) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2660 tool_calls
2661 .iter()
2662 .enumerate()
2663 .filter(|(_, c)| {
2664 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2665 })
2666 .map(|(i, c)| {
2667 let object: Option<tools::policy::Object> =
2668 serde_json::from_value(c["object"].clone()).ok();
2669 speccoder_core::oracle::AutopilotDenial {
2670 task: task.to_owned(),
2671 turn: c["turn"]
2672 .as_u64()
2673 .and_then(|n| u32::try_from(n).ok())
2674 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2675 class: object
2676 .as_ref()
2677 .map(|o| o.class().as_str().to_owned())
2678 .unwrap_or_default(),
2679 object: object.as_ref().map_or_else(
2680 || {
2681 c["arguments"]["path"]
2682 .as_str()
2683 .map(str::to_owned)
2684 .or_else(|| {
2685 c["arguments"]["argv"].as_array().map(|a| {
2686 a.iter()
2687 .filter_map(Value::as_str)
2688 .collect::<Vec<_>>()
2689 .join(" ")
2690 })
2691 })
2692 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2693 },
2694 tools::policy::Object::describe,
2695 ),
2696 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2697 }
2698 })
2699 .collect()
2700}
2701
2702#[must_use]
2704pub fn last_denials(
2705 project_state: &Path,
2706 unit: &str,
2707) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2708 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2709 .ok()
2710 .and_then(|t| serde_json::from_str(&t).ok())
2711 .unwrap_or_default()
2712}
2713
2714pub(crate) fn record_denials(
2716 project_state: &Path,
2717 unit: &str,
2718 denials: &[speccoder_core::oracle::AutopilotDenial],
2719) {
2720 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2721 if denials.is_empty() {
2722 let _ = std::fs::remove_file(file);
2723 return;
2724 }
2725 let _ = write_atomic(
2726 &file,
2727 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2728 );
2729}
2730
2731#[cfg(test)]
2732mod tests {
2733 use super::*;
2734
2735 fn draft(content: &str) -> Draft {
2736 Draft {
2737 requirement: DraftRequirement {
2738 key: "US1".to_owned(),
2739 title: "Greet".to_owned(),
2740 text: "### User Story 1 - Greet".to_owned(),
2741 sha256: "sha256:w".to_owned(),
2742 },
2743 samples: 2,
2744 tests: vec![DraftTest {
2745 path: "tests/acceptance_us1.rs".to_owned(),
2746 sha256: digest_of(content),
2747 scenarios: vec!["US1-1".to_owned()],
2748 argv: vec!["cargo".to_owned(), "test".to_owned()],
2749 red_class: RedClass::Assertion,
2750 authored_by: AuthoredBy::Model,
2751 edited_by: None,
2752 }],
2753 discarded: Vec::new(),
2754 rejected: Vec::new(),
2755 expectations: Samples::default(),
2756 untestable: Samples::default(),
2757 ambiguities: Vec::new(),
2758 held_out: HeldOutSummary {
2759 state: "absent".to_owned(),
2760 count: 0,
2761 manifest_sha256: None,
2762 reason: None,
2763 discarded: Vec::new(),
2764 },
2765 sealed: None,
2766 notes: Vec::new(),
2767 runs: Vec::new(),
2768 context: Vec::new(),
2769 }
2770 }
2771
2772 #[test]
2773 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2774 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2775 let d = draft("#[test] fn t() {}");
2776 let files = vec![(
2777 "tests/acceptance_us1.rs".to_owned(),
2778 "#[test] fn t() {}".to_owned(),
2779 )];
2780 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2781 let (back, contents) = load(state.path(), "001-greet", "US1")
2782 .unwrap_or_else(|e| panic!("{e:?}"))
2783 .unwrap_or_else(|| panic!("stored"));
2784 assert_eq!(back, d);
2785 assert_eq!(contents, files);
2786 std::fs::write(
2787 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2788 "changed",
2789 )
2790 .unwrap_or_else(|e| panic!("{e}"));
2791 assert!(load(state.path(), "001-greet", "US1").is_err());
2792 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2793 assert!(
2794 load(state.path(), "001-greet", "US2")
2795 .unwrap_or_default()
2796 .is_none()
2797 );
2798 }
2799
2800 #[test]
2801 fn the_manifest_names_paths_contents_and_commands() {
2802 let argv = vec!["cargo".to_owned()];
2803 let other = vec!["make".to_owned()];
2804 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2805 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2806 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2807 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2808 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2809 assert_eq!(
2810 both,
2811 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2812 );
2813 let mut d = draft("x");
2814 assert!(d.manifest().is_some());
2815 d.tests[0].red_class = RedClass::Passed;
2816 assert!(d.manifest().is_none());
2817 assert_eq!(slug("cap: a / b"), "cap__a___b");
2818 }
2819
2820 #[test]
2821 fn a_lapse_is_noted_once() {
2822 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2823 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
2824 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
2825 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
2826 }
2827}