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 },
412 strength_target: cfg.strength_target,
413 calls: cfg.samples,
414 ambiguity_check: cfg.samples >= 2,
415 manifest_sha256: None,
416 error: (!cfg.acceptance)
417 .then(|| "acceptance tests are off ([acceptance] generate = false)".to_owned()),
418 };
419 let loaded = load(project_state, unit, &req.key);
420 let draft = match loaded {
421 Ok(d) => d,
422 Err(t) => {
423 v.state = OracleState::Failed;
424 v.error = Some(t.0);
425 None
426 }
427 };
428 let current = draft
429 .as_ref()
430 .filter(|(d, _)| d.requirement.sha256 == digest);
431 if let Some((d, contents)) = current {
432 let content = |p: &str| {
433 contents
434 .iter()
435 .find(|(q, _)| q == p)
436 .map(|(_, c)| c.as_str())
437 .unwrap_or_default()
438 };
439 v.tests = d
440 .tests
441 .iter()
442 .map(|t| test_view(t, content(&t.path)))
443 .collect();
444 v.discarded.clone_from(&d.discarded);
445 v.rejected.clone_from(&d.rejected);
446 v.untestable.clone_from(&d.untestable.sample1);
447 v.ambiguities.clone_from(&d.ambiguities);
448 v.held_out = if cfg.held_out {
449 d.held_out.clone()
450 } else {
451 HeldOutSummary {
452 state: "off".to_owned(),
453 count: 0,
454 manifest_sha256: None,
455 reason: None,
456 }
457 };
458 v.manifest_sha256 = d.manifest();
459 let covered: Vec<&String> = d.red().flat_map(|t| t.scenarios.iter()).collect();
460 v.uncovered.retain(|s| !covered.contains(&s));
461 v.state = if d.open_ambiguities().next().is_some() {
462 OracleState::Ambiguous
463 } else if d.red().next().is_none() {
464 OracleState::NotRed
465 } else {
466 OracleState::Written
467 };
468 } else if draft.is_some() {
469 v.error = Some("written for an earlier wording: write them again".to_owned());
470 }
471 if let Some(e) = entry {
472 if e.requirement_sha256 == digest {
473 v.frozen_by = Some(e.by.clone());
474 v.frozen_at = Some(e.at);
475 v.state = match crate::pre_frozen::load(project_state, unit, &req.key) {
476 Ok(Some((rec, files))) => {
477 let drafted = current.map(|(d, _)| d);
480 v.tests = rec
481 .visible
482 .iter()
483 .map(|t| {
484 let same = drafted.and_then(|d| {
485 d.tests
486 .iter()
487 .find(|x| x.path == t.path && x.sha256 == t.sha256)
488 });
489 OracleTest {
490 path: t.path.clone(),
491 content: files
492 .iter()
493 .find(|(p, _)| *p == t.path)
494 .map(|(_, c)| c.clone())
495 .unwrap_or_default(),
496 scenarios: t.cites.clone(),
497 red_class: same.map_or_else(
498 || crate::pipeline::class_word(RedClass::Assertion),
499 |x| crate::pipeline::class_word(x.red_class),
500 ),
501 red: true,
502 authored_by: same.map_or(AuthoredBy::Model, |x| x.authored_by),
503 edited_by: same.and_then(|x| x.edited_by.clone()),
504 }
505 })
506 .collect();
507 v.manifest_sha256 = Some(e.manifest_sha256.clone());
508 let covered: Vec<&String> =
509 rec.visible.iter().flat_map(|t| t.cites.iter()).collect();
510 v.uncovered = req
511 .scenarios
512 .iter()
513 .map(|s| s.id.clone())
514 .filter(|s| !covered.contains(&s))
515 .collect();
516 if cfg.held_out {
517 v.held_out = match &rec.held_out_sealed {
518 Some(h) => HeldOutSummary {
519 state: "kept".to_owned(),
520 count: h.count,
521 manifest_sha256: Some(format!("sha256:{}", h.manifest_sha256)),
522 reason: None,
523 },
524 None => HeldOutSummary {
525 state: "absent".to_owned(),
526 count: 0,
527 manifest_sha256: None,
528 reason: Some("none_kept".to_owned()),
529 },
530 };
531 }
532 OracleState::Frozen
533 }
534 Ok(None) => OracleState::FrozenElsewhere,
535 Err(t) => {
536 v.error = Some(format!("the frozen set was tampered with: {}", t.0));
537 OracleState::Frozen
538 }
539 };
540 } else if current.is_none() {
541 v.state = OracleState::Lapsed;
542 v.frozen_on = load_frozen(project_state, unit, &req.key).map(|f| f.wording);
543 v.frozen_by = Some(e.by.clone());
544 v.frozen_at = Some(e.at);
545 }
546 }
547 v
548}
549
550pub fn open_ambiguities(
557 project_state: &Path,
558 unit: &str,
559 requirements: &[OracleRequirement],
560) -> Result<Vec<(String, String)>, Tampered> {
561 let mut out = Vec::new();
562 for r in requirements {
563 let Some((d, _)) = load(project_state, unit, &r.key)? else {
564 continue;
565 };
566 if d.requirement.sha256 != r.digest() {
567 continue;
568 }
569 out.extend(
570 d.open_ambiguities()
571 .map(|a| (d.requirement.key.clone(), a.scenario.clone())),
572 );
573 }
574 Ok(out)
575}
576
577#[must_use]
579pub fn rung(format: &dyn Adapter, root: &Path, unit: &str) -> Rung {
580 speccoder_core::ladder::rung_of(unit, &format.standing_units(root))
581}
582
583#[derive(Debug, Clone)]
585pub struct Request<'a> {
586 pub req: &'a str,
588 pub op: OracleOp,
590 pub path: Option<&'a str>,
592 pub scenario: Option<&'a str>,
594 pub reading: Option<Reading>,
596 pub text: Option<&'a str>,
598}
599
600impl Request<'_> {
601 #[must_use]
603 pub fn needs_model(&self) -> bool {
604 matches!(self.op, OracleOp::Write | OracleOp::Again)
605 || (self.op == OracleOp::Reading && self.reading == Some(Reading::B))
606 }
607}
608
609struct Ctx {
611 root: PathBuf,
612 feature: String,
613 dir: String,
614 format: &'static dyn Adapter,
615 snapshot: ProjectSnapshot,
616 plane_dir: PathBuf,
617 config: substrate::run_config::RunConfig,
618 sandbox: sandbox::Config,
619 worktree: crate::prepare::Config,
620 state: PathBuf,
622 policy: tools::policy::Policy,
623 model: Option<substrate::model_config::ModelConfig>,
624}
625
626fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
627 CommandResult::new("review", status, message).with_code(code)
628}
629
630fn open(path: &Path, feature: Option<&str>, state: &Path) -> Result<Ctx, Box<CommandResult>> {
631 let inspected = crate::inspect(path, feature);
632 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
633 return Err(Box::new(CommandResult::carried("review", &inspected)));
634 };
635 let snapshot = (**snapshot).clone();
636 let Some(f) = snapshot.feature.as_ref() else {
637 return Err(Box::new(CommandResult::new(
638 "review",
639 CommandStatus::SelectionNeeded,
640 inspected.message(),
641 )));
642 };
643 let root = PathBuf::from(&snapshot.root);
644 let config_root = crate::verify::find_config(&root).ok_or_else(|| {
645 Box::new(refuse(
646 CommandStatus::ConfigError,
647 "CONFIG-MISSING",
648 "the project has no speccoder.toml",
649 ))
650 })?;
651 let text =
652 std::fs::read_to_string(config_root.join(crate::verify::config::FILE)).unwrap_or_default();
653 let config = substrate::run_config::parse(&text).map_err(|e| {
654 Box::new(refuse(
655 CommandStatus::ConfigError,
656 "CONFIG-INVALID",
657 &format!("speccoder.toml: {e}"),
658 ))
659 })?;
660 let sandbox = sandbox::parse(&text).map_err(|e| {
661 Box::new(refuse(
662 CommandStatus::ConfigError,
663 "CONFIG-INVALID",
664 &format!("speccoder.toml: {e}"),
665 ))
666 })?;
667 let worktree_config = crate::prepare::parse(&text).map_err(|e| {
668 Box::new(refuse(
669 CommandStatus::ConfigError,
670 "CONFIG-INVALID",
671 &format!("speccoder.toml: {e}"),
672 ))
673 })?;
674 let format = crate::format::of(&snapshot);
675 let policy =
676 tools::policy::Policy::new(&crate::verify::check_argvs(&config_root), &config.allow)
677 .with_prefixes(config.allow_prefix.clone())
678 .rules(format.rules());
679 let model = substrate::model_config::parse(&text).ok().flatten();
680 Ok(Ctx {
681 feature: f.feature.name.clone(),
682 dir: f.feature.dir.clone(),
683 plane_dir: worktree::project_state_dir(state, &root),
684 root,
685 format,
686 snapshot,
687 config,
688 sandbox,
689 worktree: worktree_config,
690 state: state.to_path_buf(),
691 policy,
692 model,
693 })
694}
695
696fn project_in(root: &Path, worktree_root: &Path) -> PathBuf {
698 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
699 let top = crate::git::Git::at(root)
700 .toplevel()
701 .ok()
702 .flatten()
703 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
704 match canonical(root).strip_prefix(&top) {
705 Ok(rel) if !rel.as_os_str().is_empty() => worktree_root.join(rel),
706 _ => worktree_root.to_path_buf(),
707 }
708}
709
710pub(crate) struct UnitLock(PathBuf);
714
715impl UnitLock {
716 pub(crate) fn take(project_state: &Path, unit: &str) -> Result<Self, String> {
717 let path = project_state.join(DIR).join(format!("{unit}.lock"));
718 if let Some(parent) = path.parent() {
719 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
720 }
721 if std::fs::metadata(&path)
723 .ok()
724 .and_then(|m| m.modified().ok())
725 .and_then(|m| m.elapsed().ok())
726 .is_some_and(|age| age.as_secs() > 3600)
727 {
728 let _ = std::fs::remove_file(&path);
729 }
730 std::fs::OpenOptions::new()
731 .write(true)
732 .create_new(true)
733 .open(&path)
734 .map(|_| Self(path))
735 .map_err(|_| {
736 format!("{unit}'s tests are being written or frozen elsewhere right now: look again when that ends")
737 })
738 }
739}
740
741impl Drop for UnitLock {
742 fn drop(&mut self) {
743 let _ = std::fs::remove_file(&self.0);
744 }
745}
746
747fn stage(options: &crate::RunOptions, req: &str, stage: &str, n: u32, of: u32) {
749 if let Some(p) = &options.progress {
750 p.event(&crate::progress::Event::Oracle {
751 step: crate::progress::OracleEvent::Stage {
752 requirement: req.to_owned(),
753 stage: stage.to_owned(),
754 n,
755 of,
756 },
757 });
758 }
759}
760
761#[must_use]
764pub fn tests(
765 path: &Path,
766 feature: Option<&str>,
767 request: &Request<'_>,
768 via: Via,
769 state: Option<&Path>,
770 options: &crate::RunOptions,
771) -> CommandResult {
772 let Some(state) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
773 return refuse(
774 CommandStatus::ConfigError,
775 "NO-STATE-DIR",
776 "no state directory (set SPECCODER_STATE_DIR or HOME)",
777 );
778 };
779 if !request.needs_model() {
780 return tests_with(path, feature, request, via, &state, None, options);
781 }
782 let ctx = match open(path, feature, &state) {
783 Ok(c) => c,
784 Err(r) => return *r,
785 };
786 if let Err(r) = check(&ctx, request) {
787 return *r;
788 }
789 let Some(model) = ctx.model.as_ref() else {
790 return refuse(
791 CommandStatus::ConfigError,
792 "CONFIG",
793 "speccoder.toml has no [model] section: the test author needs a model",
794 );
795 };
796 match substrate::model_config::open_driver(model) {
797 Ok((provider, _)) => {
798 tests_with(path, feature, request, via, &state, Some(provider), options)
799 }
800 Err(e) => substrate::model_config::refused("review", &e),
801 }
802}
803
804#[must_use]
806pub fn tests_with(
807 path: &Path,
808 feature: Option<&str>,
809 request: &Request<'_>,
810 via: Via,
811 state: &Path,
812 provider: Option<Arc<dyn ModelProvider>>,
813 options: &crate::RunOptions,
814) -> CommandResult {
815 let ctx = match open(path, feature, state) {
816 Ok(c) => c,
817 Err(r) => return *r,
818 };
819 let req = match check(&ctx, request) {
820 Ok(r) => r,
821 Err(r) => return *r,
822 };
823 let _lock = match UnitLock::take(&ctx.plane_dir, &ctx.feature) {
824 Ok(l) => l,
825 Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e),
826 };
827 let who = crate::approvals::Who::local_in(&ctx.root, via);
828 let done = match request.op {
829 OracleOp::Write => write(&ctx, &req, provider, options),
830 OracleOp::Reject => reject(&ctx, &req, request, &who.name),
831 OracleOp::Again => again(&ctx, &req, request, &who.name, provider, options),
832 OracleOp::Edit => edit(&ctx, &req, request, &who.name),
833 OracleOp::Reading => reading(&ctx, &req, request, &who.name, provider, options),
834 };
835 match done {
836 Ok(message) => {
837 let r = crate::review::report_in(path, feature, Some(state));
838 let status = r.status();
839 r.reworded(status, &message)
840 }
841 Err(r) => *r,
842 }
843}
844
845fn check(ctx: &Ctx, request: &Request<'_>) -> Result<OracleRequirement, Box<CommandResult>> {
848 let unit_rung = rung(ctx.format, &ctx.root, &ctx.feature);
849 if unit_rung != Rung::Feature {
850 return Err(Box::new(
851 refuse(
852 CommandStatus::UsageError,
853 "REVIEW-TESTS-OWN-PIPELINE",
854 &format!(
855 "{} is a {} unit: its tests are written and confirmed by its own pipeline",
856 ctx.feature,
857 if unit_rung == Rung::Quick {
858 "quick-change"
859 } else {
860 "bug"
861 }
862 ),
863 )
864 .with_fix(vec![if unit_rung == Rung::Quick {
865 "speccoder do \"…\"".to_owned()
866 } else {
867 format!("speccoder bug confirm {}/<task>", ctx.feature)
868 }]),
869 ));
870 }
871 if !ctx.config.acceptance {
872 return Err(Box::new(refuse(
873 CommandStatus::ConfigError,
874 "ACCEPTANCE-OFF",
875 "acceptance tests are off ([acceptance] generate = false): no tests are written at review",
876 )));
877 }
878 let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
879 let Some(req) = requirements
880 .iter()
881 .find(|r| r.key.eq_ignore_ascii_case(request.req))
882 .cloned()
883 else {
884 return Err(Box::new(refuse(
885 CommandStatus::UsageError,
886 "REVIEW-NO-SCENARIOS",
887 &format!(
888 "the spec has no requirement {}; it names {}",
889 request.req,
890 if requirements.is_empty() {
891 "none with scenarios".to_owned()
892 } else {
893 requirements
894 .iter()
895 .map(|r| r.key.as_str())
896 .collect::<Vec<_>>()
897 .join(", ")
898 }
899 ),
900 )));
901 };
902 if req.scenarios.is_empty() {
903 return Err(Box::new(refuse(
904 CommandStatus::UsageError,
905 "REVIEW-NO-SCENARIOS",
906 &format!(
907 "{} has no acceptance scenarios to write tests from",
908 req.key
909 ),
910 )));
911 }
912 let (entries, _) = crate::review::oracle_entries(&ctx.root, &ctx.dir)
915 .map_err(|e| Box::new(refuse(CommandStatus::NeedsHuman, "REVIEW-UNREADABLE", &e)))?;
916 if let Some(e) = entries
917 .get(&req.key)
918 .filter(|e| e.requirement_sha256 == req.digest())
919 {
920 return Err(Box::new(refuse(
921 CommandStatus::UsageError,
922 "ORACLE-FROZEN",
923 &format!(
924 "{}'s tests were frozen with the approval by {}: the run uses them as frozen — reword {} to lapse the freeze, then write its tests again",
925 req.key, e.by, req.key
926 ),
927 )));
928 }
929 Ok(req)
930}
931
932type Outcome = Result<String, Box<CommandResult>>;
933
934fn failed(message: impl Into<String>) -> Box<CommandResult> {
935 Box::new(refuse(
936 CommandStatus::RunFailed,
937 "ORACLE-AUTHOR-FAILED",
938 &message.into(),
939 ))
940}
941
942fn usage(message: &str) -> Box<CommandResult> {
943 Box::new(refuse(CommandStatus::UsageError, "USAGE", message))
944}
945
946fn existing(ctx: &Ctx, req: &OracleRequirement) -> Result<Loaded, Box<CommandResult>> {
947 match load(&ctx.plane_dir, &ctx.feature, &req.key) {
948 Ok(Some((d, c))) if d.requirement.sha256 == req.digest() => Ok((d, c)),
949 Ok(Some(_)) => Err(usage(&format!(
950 "{}'s tests were written for an earlier wording: write them again (`speccoder review tests {} --write`)",
951 req.key, req.key
952 ))),
953 Ok(None) => Err(usage(&format!(
954 "{} has no tests yet: `speccoder review tests {} --write`",
955 req.key, req.key
956 ))),
957 Err(t) => Err(Box::new(refuse(
958 CommandStatus::NeedsHuman,
959 "ORACLE-TAMPERED",
960 &t.0,
961 ))),
962 }
963}
964
965struct Author<'a> {
967 ctx: &'a Ctx,
968 rt: tokio::runtime::Runtime,
969 plane: substrate::Plane,
970 bundle: Value,
971 instructions: Vec<crate::instructions::Instruction>,
972 test_layout: String,
974}
975
976impl<'a> Author<'a> {
977 fn open(
978 ctx: &'a Ctx,
979 req: &OracleRequirement,
980 provider: Option<Arc<dyn ModelProvider>>,
981 options: &crate::RunOptions,
982 ) -> Result<Self, Box<CommandResult>> {
983 let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
984 return Err(Box::new(refuse(
985 CommandStatus::ConfigError,
986 "CONFIG",
987 "the test author needs a model ([model] in speccoder.toml)",
988 )));
989 };
990 let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
991 let plane = rt
992 .block_on(async {
993 substrate::Plane::open_with(
994 &ctx.plane_dir,
995 model,
996 provider,
997 None,
998 substrate::Hooks {
999 format: Some(ctx.format),
1000 progress: options.progress.clone(),
1001 ..substrate::Hooks::default()
1002 },
1003 )
1004 })
1005 .map_err(|e| failed(e.0))?;
1006 let unit_dir = ctx.root.join(&ctx.dir);
1007 let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
1008 let bundle = json!({
1009 "feature": ctx.feature,
1010 "constitution": constitution,
1011 "plan": plan,
1012 "story": req.text,
1013 "task": {
1014 "id": format!("req-{}", req.key),
1015 "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1016 "phase": "Review",
1017 "subsection": "",
1018 "story": req.key,
1019 "parallel": false,
1020 },
1021 "dependencies": [],
1022 });
1023 let instructions =
1024 crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1025 Ok(Self {
1026 ctx,
1027 rt,
1028 plane,
1029 bundle,
1030 instructions,
1031 test_layout: crate::layout::text_at(&ctx.root),
1032 })
1033 }
1034
1035 fn context(
1037 &self,
1038 req: &OracleRequirement,
1039 notes: &[String],
1040 ) -> speccoder_core::context::ContextManifest {
1041 let mut m = crate::context::Manifest::new();
1042 m.push(
1043 ContextKind::Story,
1044 "person",
1045 "/requirement",
1046 req.text.as_bytes(),
1047 );
1048 for s in &req.scenarios {
1049 m.push(
1050 ContextKind::Scenario,
1051 "person",
1052 &format!("/scenarios/{}", s.id),
1053 s.text.as_bytes(),
1054 );
1055 }
1056 m.push(
1057 ContextKind::Constitution,
1058 "worktree",
1059 "/bundle/constitution",
1060 self.bundle["constitution"]
1061 .as_str()
1062 .unwrap_or_default()
1063 .as_bytes(),
1064 );
1065 m.push(
1066 ContextKind::Plan,
1067 "worktree",
1068 "/bundle/plan",
1069 self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1070 );
1071 m.push(
1072 ContextKind::TestLayout,
1073 "worktree",
1074 "/test_layout",
1075 self.test_layout.as_bytes(),
1076 );
1077 for (i, n) in notes.iter().enumerate() {
1078 let kind = if n.starts_with("Rejected `") {
1079 ContextKind::Rejection
1080 } else if n.starts_with("Comment ") {
1081 ContextKind::Comment
1082 } else {
1083 ContextKind::Note
1084 };
1085 m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1086 }
1087 m.finish()
1088 }
1089
1090 #[allow(clippy::too_many_arguments)]
1092 fn call(
1093 &self,
1094 req: &OracleRequirement,
1095 files: &[String],
1096 manifest: &str,
1097 notes: &[String],
1098 again: Option<(&str, &str)>,
1099 sample: u8,
1100 ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1101 let context = self.context(req, notes);
1102 let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1103 bundle: self.bundle.clone(),
1104 requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1105 scenarios: &req.scenarios,
1106 files,
1107 manifest,
1108 notes,
1109 again,
1110 instructions: &self.instructions,
1111 context: &context,
1112 sample,
1113 test_layout: Some(&self.test_layout),
1114 });
1115 let task = format!("req-{}", req.key);
1116 let outcome = self
1117 .rt
1118 .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1119 .map_err(|e| format!("the test author failed: {}", e.0))?;
1120 let output = outcome
1121 .output
1122 .as_ref()
1123 .map(|o| o.peek().clone())
1124 .unwrap_or_default();
1125 let answer = output["answer"].clone();
1126 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1127 {
1128 let status = format!("{:?}", outcome.status);
1129 return Err(substrate::provider_refusal(&status).map_or_else(
1130 || {
1131 format!(
1132 "the test author did not answer: {}",
1133 substrate::why_not(&outcome.status)
1134 )
1135 },
1136 |r| format!("the model provider refused the request: {r}"),
1137 ));
1138 }
1139 Ok((answer, outcome.run_id.to_string(), context.entries))
1140 }
1141}
1142
1143fn expectations(
1149 answer: &Value,
1150 req: &OracleRequirement,
1151) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1152 let canonical = |id: &str| {
1153 req.scenarios
1154 .iter()
1155 .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1156 .map(|s| s.id.clone())
1157 .ok_or_else(|| {
1158 format!(
1159 "the test author's expectations name `{id}`, which {} does not have",
1160 req.key
1161 )
1162 })
1163 };
1164 let list = |field: &str| -> Result<Value, String> {
1165 match &answer[field] {
1166 Value::Null => Ok(Value::Array(Vec::new())),
1167 v @ Value::Array(_) => Ok(v.clone()),
1168 _ => Err(format!("the test author's `{field}` is not a list")),
1169 }
1170 };
1171 let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1172 .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1173 let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1174 .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1175 for x in &mut e {
1176 x.scenario = canonical(&x.scenario)?;
1177 }
1178 for x in &mut u {
1179 x.scenario = canonical(&x.scenario)?;
1180 }
1181 if e.is_empty() && u.is_empty() {
1182 return Err(
1183 "the test author stated no expectation for any scenario: the readings cannot be compared"
1184 .to_owned(),
1185 );
1186 }
1187 Ok((e, u))
1188}
1189
1190struct Bench {
1193 scratch: worktree::Scratch,
1194 project: PathBuf,
1195 runner: sandbox::Runner,
1196 tmp: PathBuf,
1197}
1198
1199impl Bench {
1200 fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1201 let tmp = ctx.plane_dir.join("tmp");
1202 let scratch = worktree::scratch(&ctx.root, &tmp)
1203 .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1204 let project = project_in(&ctx.root, &scratch.path);
1205 let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1206 .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1207 if !runner.is_sandboxed() {
1210 return Err(Box::new(
1211 refuse(
1212 CommandStatus::ConfigError,
1213 "SANDBOX-UNAVAILABLE",
1214 &format!(
1215 "the red check runs model-written tests and needs the OS sandbox: {}",
1216 notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1217 ),
1218 )
1219 .with_fix(vec![
1220 "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1221 ]),
1222 ));
1223 }
1224 crate::include::copy(&scratch.path, &project, &ctx.root, &ctx.worktree.include).map_err(
1226 |e| {
1227 Box::new(refuse(
1228 CommandStatus::ConfigError,
1229 "PREPARE-FAILED",
1230 &format!("the files [worktree] include names could not be copied: {e}"),
1231 ))
1232 },
1233 )?;
1234 if ctx.worktree.prepare && crate::prepare::needed(&project) {
1235 crate::prepare::prepare(&crate::prepare::Prepare {
1236 worktree: &scratch.path,
1237 project: &project,
1238 config: &ctx.worktree,
1239 runner: &runner,
1240 state: Some(&ctx.state),
1241 })
1242 .map_err(|f| {
1243 Box::new(
1244 refuse(CommandStatus::ConfigError, "PREPARE-FAILED", &f.message)
1245 .with_fix(f.fix)
1246 .with_commands(f.commands),
1247 )
1248 })?;
1249 }
1250 Ok(Self {
1251 scratch,
1252 project,
1253 runner,
1254 tmp,
1255 })
1256 }
1257
1258 fn files(&self) -> (Vec<String>, String) {
1259 let files: Vec<String> = worktree::checkpoint(&self.scratch.path, &self.tmp)
1260 .and_then(|c| worktree::files(&self.scratch.path, &c))
1261 .unwrap_or_default()
1262 .into_iter()
1263 .take(400)
1264 .collect();
1265 let manifest = crate::pipeline::MANIFESTS
1266 .iter()
1267 .find_map(|m| {
1268 std::fs::read_to_string(self.project.join(m))
1269 .ok()
1270 .map(|t| format!("{m}:\n{t}"))
1271 })
1272 .map(|t| t.chars().take(8192).collect::<String>())
1273 .unwrap_or_default();
1274 (files, manifest)
1275 }
1276
1277 fn red(
1283 &self,
1284 tests: &[(String, String, Vec<String>)],
1285 ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1286 let mut out = Vec::new();
1287 for (path, content, argv) in tests {
1288 let full = self.project.join(path);
1289 let _ = std::fs::remove_file(&full);
1290 let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1291 let (bare, summary) = run();
1292 if classify_red(&bare, summary.as_ref()).is_red() {
1293 out.push(Err("red_without_it"));
1294 continue;
1295 }
1296 if let Some(parent) = full.parent() {
1297 std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1298 }
1299 std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1300 let (first, summary) = run();
1301 let class = classify_red(&first, summary.as_ref());
1302 let class = if !class.is_red() {
1303 class
1304 } else {
1305 let (second, summary) = run();
1306 if classify_red(&second, summary.as_ref()).is_red() {
1307 class
1308 } else {
1309 RedClass::Flaky
1310 }
1311 };
1312 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1313 out.push(Ok(class));
1314 }
1315 Ok(out)
1316 }
1317}
1318
1319fn keep(
1321 ctx: &Ctx,
1322 bench: &Bench,
1323 answer: &Value,
1324 req: &OracleRequirement,
1325 taken: &[(String, Vec<String>)],
1326) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1327 let (valid, refused) =
1328 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios);
1329 let mut discarded: Vec<OracleDiscarded> = refused
1330 .into_iter()
1331 .map(|r| OracleDiscarded {
1332 path: r.path,
1333 why: r.why.to_owned(),
1334 })
1335 .collect();
1336 let set_argv: Vec<String> = crate::pipeline::acceptance::argv_of(&answer["argv"]);
1337 let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1338 let mut kept = Vec::new();
1339 for f in valid {
1340 let mut discard = |why: &str| {
1341 discarded.push(OracleDiscarded {
1342 path: f.path.clone(),
1343 why: why.to_owned(),
1344 });
1345 };
1346 if f.cited.is_empty() {
1347 discard("cites_no_scenario");
1348 continue;
1349 }
1350 if taken.iter().any(|(p, _)| *p == f.path)
1351 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1352 {
1353 discard("exists");
1354 continue;
1355 }
1356 let argv = if f.argv.is_empty() {
1357 set_argv.clone()
1358 } else {
1359 f.argv.clone()
1360 };
1361 if taken.iter().any(|(_, a)| *a == argv)
1364 || kept
1365 .iter()
1366 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1367 {
1368 discard("command_shared");
1369 continue;
1370 }
1371 if argv.is_empty()
1372 || !crate::pipeline::test_command_allowed(
1373 &ctx.policy,
1374 Some(&layout),
1375 &argv,
1376 &[f.path.as_str()],
1377 )
1378 {
1379 discard("command_not_allowed");
1380 continue;
1381 }
1382 if kept.len() + taken.len() >= LIMIT {
1383 discard("limit");
1384 continue;
1385 }
1386 kept.push((f.path, f.content, f.cited, argv));
1387 }
1388 (kept, discarded)
1389}
1390
1391fn write(
1394 ctx: &Ctx,
1395 req: &OracleRequirement,
1396 provider: Option<Arc<dyn ModelProvider>>,
1397 options: &crate::RunOptions,
1398) -> Outcome {
1399 let samples = ctx.config.oracle_samples.clamp(1, 2);
1400 let author = Author::open(ctx, req, provider, options)?;
1401 let bench = Bench::open(ctx)?;
1402 let (files, manifest) = bench.files();
1403 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1406 Box::new(
1407 refuse(
1408 CommandStatus::NeedsHuman,
1409 "ORACLE-TAMPERED",
1410 &format!(
1411 "{}: writing anew would lose the rejections and notes it holds",
1412 t.0
1413 ),
1414 )
1415 .with_fix(vec![format!(
1416 "→ restore it, or remove {} to start {}'s tests over",
1417 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1418 req.key
1419 )]),
1420 )
1421 })?;
1422 let (rejected, mut notes) = earlier
1423 .as_ref()
1424 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1425 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1426 .unwrap_or_default();
1427 notes.dedup();
1428 let told: Vec<String> = rejected
1429 .iter()
1430 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1431 .chain(notes.iter().cloned())
1432 .collect();
1433 let of = u32::from(samples);
1434 stage(options, &req.key, "writing", 1, of);
1435 let (first, run1, mut context) = author
1436 .call(req, &files, &manifest, &told, None, 1)
1437 .map_err(failed)?;
1438 let read = |answer: &Value| {
1441 let got = expectations(answer, req);
1442 if samples >= 2 {
1443 got.map_err(failed)
1444 } else {
1445 Ok(got.unwrap_or_default())
1446 }
1447 };
1448 let (e1, u1) = read(&first)?;
1449 let mut runs = vec![run1];
1450 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1451 if samples >= 2 {
1452 stage(options, &req.key, "writing", 2, of);
1453 let (second, run2, more) = author
1454 .call(req, &files, &manifest, &told, None, 2)
1455 .map_err(failed)?;
1456 (e2, u2) = read(&second)?;
1457 runs.push(run2);
1458 for c in more {
1459 if !context.contains(&c) {
1460 context.push(c);
1461 }
1462 }
1463 stage(options, &req.key, "comparing", 1, 1);
1464 }
1465 let ambiguities = if samples >= 2 {
1466 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1467 } else {
1468 Vec::new()
1469 };
1470 let (kept, mut discarded) = keep(ctx, &bench, &first, req, &[]);
1471 stage(options, &req.key, "checking_red", 1, 1);
1472 let plan: Vec<(String, String, Vec<String>)> = kept
1473 .iter()
1474 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1475 .collect();
1476 let classes = bench.red(&plan).map_err(failed)?;
1477 let mut tests: Vec<DraftTest> = Vec::new();
1478 let mut contents: Vec<(String, String)> = Vec::new();
1479 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1480 match class {
1481 Ok(red_class) => {
1482 tests.push(DraftTest {
1483 path: path.clone(),
1484 sha256: digest_of(&content),
1485 scenarios: cited,
1486 argv,
1487 red_class,
1488 authored_by: AuthoredBy::Model,
1489 edited_by: None,
1490 });
1491 contents.push((path, content));
1492 }
1493 Err(why) => discarded.push(OracleDiscarded {
1494 path,
1495 why: why.to_owned(),
1496 }),
1497 }
1498 }
1499 let (held_out, sealed) = held_out(ctx, &author, &bench, req, &tests, options);
1500 let draft = Draft {
1501 requirement: DraftRequirement {
1502 key: req.key.clone(),
1503 title: req.title.clone(),
1504 text: req.text.clone(),
1505 sha256: req.digest(),
1506 },
1507 samples,
1508 tests,
1509 discarded,
1510 rejected,
1511 expectations: Samples {
1512 sample1: e1,
1513 sample2: e2,
1514 },
1515 untestable: Samples {
1516 sample1: u1,
1517 sample2: u2,
1518 },
1519 ambiguities,
1520 held_out,
1521 sealed,
1522 notes,
1523 runs,
1524 context,
1525 };
1526 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1527 let red = draft.red().count();
1528 let open = draft.open_ambiguities().count();
1529 Ok(format!(
1530 "wrote {} test(s) for {}: {red} red{}{}",
1531 draft.tests.len(),
1532 req.key,
1533 if draft.held_out.state == "kept" {
1534 format!(", {} held out", draft.held_out.count)
1535 } else {
1536 String::new()
1537 },
1538 if open > 0 {
1539 format!(
1540 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1541 req.key, req.key
1542 )
1543 } else {
1544 String::new()
1545 }
1546 ))
1547}
1548
1549fn held_out(
1553 ctx: &Ctx,
1554 author: &Author<'_>,
1555 bench: &Bench,
1556 req: &OracleRequirement,
1557 tests: &[DraftTest],
1558 options: &crate::RunOptions,
1559) -> (HeldOutSummary, Option<SealedRef>) {
1560 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1561 state: state.to_owned(),
1562 count: 0,
1563 manifest_sha256: None,
1564 reason: reason.map(str::to_owned),
1565 };
1566 if !ctx.config.held_out {
1567 return (summary("off", None), None);
1568 }
1569 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1570 if red.is_empty() {
1571 return (summary("absent", Some("not_red")), None);
1572 }
1573 stage(options, &req.key, "held_out", 1, 1);
1574 let visible = crate::pipeline::PreparedAcceptance {
1575 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1576 argv: red.first().map(|t| t.argv.clone()),
1577 more_argv: Vec::new(),
1578 red: true,
1579 red_class: Some(RedClass::Assertion),
1580 held_out: None,
1581 generation_failed: None,
1582 skip_cause: None,
1583 };
1584 let task = format!("req-{}", req.key);
1585 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1586 let (report, sha) = crate::pipeline::prepare_held_out(
1587 &crate::pipeline::AcceptanceSetup {
1588 rt: &author.rt,
1589 plane: &author.plane,
1590 project: &bench.project,
1591 worktree: &bench.scratch.path,
1592 tmp: &bench.tmp,
1593 policy: &ctx.policy,
1594 feature: &ctx.feature,
1595 task: &task,
1596 bundle: author.bundle.clone(),
1597 scenarios: &req.scenarios,
1598 instructions: &author.instructions,
1599 runner: &bench.runner,
1600 layout: None,
1601 story_files: &[],
1602 },
1603 &visible,
1604 &req.scenarios,
1605 &store,
1606 false,
1607 );
1608 match sha {
1609 Some(sha) => {
1610 let count = report["kept"]
1611 .as_u64()
1612 .and_then(|n| usize::try_from(n).ok())
1613 .unwrap_or(0);
1614 (
1615 HeldOutSummary {
1616 state: "kept".to_owned(),
1617 count,
1618 manifest_sha256: Some(format!("sha256:{sha}")),
1619 reason: None,
1620 },
1621 Some(SealedRef {
1622 key: req.key.clone(),
1623 manifest_sha256: sha,
1624 count,
1625 }),
1626 )
1627 }
1628 None => (
1629 summary(
1630 "absent",
1631 Some(report["reason"].as_str().unwrap_or("none_kept")),
1632 ),
1633 None,
1634 ),
1635 }
1636}
1637
1638fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1639 let Some(path) = r.path else {
1640 return Err(usage("name the test to reject (--reject PATH)"));
1641 };
1642 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1643 return Err(usage(
1644 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1645 ));
1646 };
1647 let (mut d, contents) = existing(ctx, req)?;
1648 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1649 return Err(usage(&format!("{} has no test {path}", req.key)));
1650 };
1651 d.tests.remove(at);
1652 d.rejected.push(OracleRejected {
1653 path: path.to_owned(),
1654 reason: reason.to_owned(),
1655 by: by.to_owned(),
1656 at: now(),
1657 });
1658 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1659 Ok(format!(
1660 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1661 req.key, req.key
1662 ))
1663}
1664
1665fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1666 let Some(path) = r.path else {
1667 return Err(usage("name the test to edit (--edit PATH)"));
1668 };
1669 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1670 return Err(usage("an edit needs the test's new content (--from FILE)"));
1671 };
1672 let (mut d, mut contents) = existing(ctx, req)?;
1673 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1674 return Err(usage(&format!("{} has no test {path}", req.key)));
1675 };
1676 let bench = Bench::open(ctx)?;
1677 let plan = [(
1678 path.to_owned(),
1679 content.to_owned(),
1680 d.tests[at].argv.clone(),
1681 )];
1682 let checked = bench
1683 .red(&plan)
1684 .map_err(failed)?
1685 .pop()
1686 .unwrap_or(Ok(RedClass::Unclassified));
1687 let class = checked.unwrap_or(RedClass::Unclassified);
1689 let t = &mut d.tests[at];
1690 t.sha256 = digest_of(content);
1691 t.red_class = class;
1692 t.authored_by = AuthoredBy::Person;
1693 t.edited_by = Some(by.to_owned());
1694 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1695 c.1 = content.to_owned();
1696 }
1697 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1698 Ok(format!(
1699 "edited {path} of {}: {}",
1700 req.key,
1701 if class.is_red() {
1702 format!("red ({})", crate::pipeline::class_word(class))
1703 } else if checked.is_err() {
1704 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1705 } else {
1706 format!(
1707 "not red ({}) — no evidence, it cannot be frozen",
1708 crate::pipeline::class_word(class)
1709 )
1710 }
1711 ))
1712}
1713
1714#[allow(clippy::too_many_arguments)]
1717fn rewrite(
1718 ctx: &Ctx,
1719 req: &OracleRequirement,
1720 mut d: Draft,
1721 mut contents: Vec<(String, String)>,
1722 replaced: &[String],
1723 note: String,
1724 again: Option<(&str, &str)>,
1725 only: Option<&str>,
1726 provider: Option<Arc<dyn ModelProvider>>,
1727 options: &crate::RunOptions,
1728) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1729 let author = Author::open(ctx, req, provider, options)?;
1730 let bench = Bench::open(ctx)?;
1731 let (files, manifest) = bench.files();
1732 d.notes.push(note);
1733 let told = d.all_notes();
1734 stage(options, &req.key, "writing", 1, 1);
1735 let (answer, run, context) = author
1736 .call(req, &files, &manifest, &told, again, 1)
1737 .map_err(failed)?;
1738 d.runs.push(run);
1739 for c in context {
1740 if !d.context.contains(&c) {
1741 d.context.push(c);
1742 }
1743 }
1744 let keep_paths: Vec<(String, Vec<String>)> = d
1745 .tests
1746 .iter()
1747 .filter(|t| !replaced.contains(&t.path))
1748 .map(|t| (t.path.clone(), t.argv.clone()))
1749 .collect();
1750 let mut answer = answer;
1752 if let Some(files) = answer["files"].as_array_mut() {
1753 files.retain(|f| {
1754 only.is_none_or(|s| {
1755 f["scenarios"]
1756 .as_array()
1757 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1758 })
1759 });
1760 }
1761 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1762 if new.is_empty() {
1763 return Err(failed(format!(
1764 "the test author wrote no usable test{}",
1765 discarded
1766 .first()
1767 .map(|x| format!(" ({} {})", x.path, x.why))
1768 .unwrap_or_default()
1769 )));
1770 }
1771 d.tests.retain(|t| !replaced.contains(&t.path));
1772 contents.retain(|(p, _)| !replaced.contains(p));
1773 d.discarded.extend(discarded);
1774 stage(options, &req.key, "checking_red", 1, 1);
1775 let plan: Vec<(String, String, Vec<String>)> = new
1777 .iter()
1778 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1779 .collect();
1780 let classes = bench.red(&plan).map_err(failed)?;
1781 let mut written = Vec::new();
1782 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1783 let red_class = match class {
1784 Ok(c) => c,
1785 Err(why) => {
1786 d.discarded.push(OracleDiscarded {
1787 path,
1788 why: why.to_owned(),
1789 });
1790 continue;
1791 }
1792 };
1793 d.tests.push(DraftTest {
1794 sha256: digest_of(&content),
1795 scenarios: cited,
1796 argv,
1797 red_class,
1798 authored_by: AuthoredBy::Model,
1799 edited_by: None,
1800 path: path.clone(),
1801 });
1802 contents.push((path.clone(), content));
1803 written.push(path);
1804 }
1805 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1806 Ok((d, written))
1807}
1808
1809fn again(
1810 ctx: &Ctx,
1811 req: &OracleRequirement,
1812 r: &Request<'_>,
1813 _by: &str,
1814 provider: Option<Arc<dyn ModelProvider>>,
1815 options: &crate::RunOptions,
1816) -> Outcome {
1817 let Some(path) = r.path else {
1818 return Err(usage("name the test to write again (--again PATH)"));
1819 };
1820 let comment = r
1821 .text
1822 .map(str::trim)
1823 .filter(|t| !t.is_empty())
1824 .unwrap_or("write it again");
1825 let (d, contents) = existing(ctx, req)?;
1826 let Some(content) = contents
1827 .iter()
1828 .find(|(p, _)| p == path)
1829 .map(|(_, c)| c.clone())
1830 else {
1831 return Err(usage(&format!("{} has no test {path}", req.key)));
1832 };
1833 let (d, written) = rewrite(
1834 ctx,
1835 req,
1836 d,
1837 contents,
1838 &[path.to_owned()],
1839 format!("Comment on `{path}`: {comment}"),
1840 Some((path, &content)),
1841 None,
1842 provider,
1843 options,
1844 )?;
1845 let red = d
1846 .tests
1847 .iter()
1848 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
1849 .count();
1850 Ok(format!(
1851 "wrote {path} of {} again: {}",
1852 req.key,
1853 if written.is_empty() {
1854 "nothing kept — its command is red without it, so it decides nothing".to_owned()
1855 } else if red == written.len() {
1856 "red".to_owned()
1857 } else {
1858 "not red — no evidence, it cannot be frozen".to_owned()
1859 }
1860 ))
1861}
1862
1863fn reading(
1864 ctx: &Ctx,
1865 req: &OracleRequirement,
1866 r: &Request<'_>,
1867 by: &str,
1868 provider: Option<Arc<dyn ModelProvider>>,
1869 options: &crate::RunOptions,
1870) -> Outcome {
1871 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
1872 return Err(usage(
1873 "name the reading and the scenario (--reading A|B US1-2)",
1874 ));
1875 };
1876 let (mut d, contents) = existing(ctx, req)?;
1877 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
1878 return Err(usage(&format!(
1879 "{} has no ambiguity on {scenario}",
1880 req.key
1881 )));
1882 };
1883 item.resolution = Some(Resolution {
1884 reading: choice,
1885 by: by.to_owned(),
1886 at: now(),
1887 });
1888 let meant = if choice == Reading::A {
1889 item.a.clone()
1890 } else {
1891 item.b.clone()
1892 };
1893 if choice == Reading::A {
1894 d.notes.push(format!(
1895 "For {scenario} the person means reading A: given {}, expect {}",
1896 meant.given, meant.expect
1897 ));
1898 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1899 return Ok(format!(
1900 "{scenario} of {}: reading A is meant — the tests stay",
1901 req.key
1902 ));
1903 }
1904 let replaced: Vec<String> = d
1905 .tests
1906 .iter()
1907 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
1908 .map(|t| t.path.clone())
1909 .collect();
1910 let (_, written) = rewrite(
1911 ctx,
1912 req,
1913 d,
1914 contents,
1915 &replaced,
1916 format!(
1917 "For {scenario} the person means reading B: given {}, expect {}",
1918 meant.given, meant.expect
1919 ),
1920 None,
1921 Some(scenario),
1922 provider,
1923 options,
1924 )?;
1925 Ok(format!(
1926 "{scenario} of {}: reading B is meant — wrote {} anew",
1927 req.key,
1928 written.join(", ")
1929 ))
1930}
1931
1932#[derive(Debug, Clone)]
1935pub struct Freezable {
1936 pub req: OracleRequirement,
1938 pub draft: Draft,
1940 pub contents: Vec<(String, String)>,
1942 pub manifest: String,
1944}
1945
1946pub fn freezable(
1949 project_state: &Path,
1950 unit: &str,
1951 requirements: &[OracleRequirement],
1952) -> Result<Vec<Freezable>, Tampered> {
1953 let mut out = Vec::new();
1954 for r in requirements {
1955 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
1956 continue;
1957 };
1958 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
1959 continue;
1960 }
1961 let Some(manifest) = draft.manifest() else {
1962 continue;
1963 };
1964 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
1965 out.push(Freezable {
1966 req: r.clone(),
1967 contents: contents
1968 .into_iter()
1969 .filter(|(p, _)| red.contains(p))
1970 .collect(),
1971 draft,
1972 manifest,
1973 });
1974 }
1975 Ok(out)
1976}
1977
1978pub fn freeze(
1982 project_state: &Path,
1983 unit: &str,
1984 f: &Freezable,
1985 approval_digest: &str,
1986 decided: Decided,
1987) -> Result<PreFrozenAcceptance, String> {
1988 let sealed = match &f.draft.sealed {
1989 Some(s) => {
1990 crate::held_out::open_sealed(project_state, unit, s)
1991 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
1992 Some(s.clone())
1993 }
1994 None => None,
1995 };
1996 let red: Vec<&DraftTest> = f.draft.red().collect();
1997 let record = PreFrozenAcceptance {
1998 unit: unit.to_owned(),
1999 requirement: f.req.key.clone(),
2000 wording_digest: f.draft.requirement.sha256.clone(),
2001 approval_digest: approval_digest.to_owned(),
2002 visible: red
2003 .iter()
2004 .map(|t| VisibleTest {
2005 path: t.path.clone(),
2006 sha256: t.sha256.clone(),
2007 cites: t.scenarios.clone(),
2008 })
2009 .collect(),
2010 held_out_sealed: sealed,
2011 producer: Producer::Review,
2012 decided,
2013 };
2014 crate::pre_frozen::store(project_state, &record, &f.contents)?;
2015 let frozen = Frozen {
2016 requirement_sha256: f.draft.requirement.sha256.clone(),
2017 wording: format!(
2018 "{}\n{}",
2019 f.draft.requirement.title, f.draft.requirement.text
2020 ),
2021 manifest_sha256: f.manifest.clone(),
2022 tests: red
2023 .iter()
2024 .map(|t| FrozenTest {
2025 path: t.path.clone(),
2026 sha256: t.sha256.clone(),
2027 argv: t.argv.clone(),
2028 scenarios: t.scenarios.clone(),
2029 })
2030 .collect(),
2031 };
2032 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2033 write_atomic(
2034 &dir(project_state, unit, &f.req.key).join("frozen.json"),
2035 &text,
2036 )?;
2037 Ok(record)
2038}
2039
2040#[derive(Debug)]
2042pub(crate) enum TaskReuse {
2043 Ready {
2045 record: Box<PreFrozenAcceptance>,
2047 files: Vec<(String, String)>,
2049 tests: Vec<crate::pipeline::ReusedTest>,
2051 manifest: String,
2053 },
2054 Reused {
2056 req: String,
2058 },
2059 Elsewhere {
2061 req: String,
2063 },
2064 Lapsed {
2066 req: String,
2068 frozen: String,
2070 current: String,
2072 },
2073 Tampered(String),
2075 Unreadable(String),
2077}
2078
2079pub(crate) fn for_task(
2084 project_state: &Path,
2085 project: &Path,
2086 wt_root: &Path,
2087 format: &dyn Adapter,
2088 snapshot: &ProjectSnapshot,
2089 scenarios: &[speccoder_core::spec::ScenarioRef],
2090) -> Option<TaskReuse> {
2091 let f = snapshot.feature.as_ref()?;
2092 let unit = f.feature.name.as_str();
2093 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2094 Ok(x) => x,
2095 Err(e) => return Some(TaskReuse::Unreadable(e)),
2097 };
2098 if entries.is_empty() {
2099 return None;
2100 }
2101 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2102 let requirements = format.oracle_requirements(snapshot);
2103 let (req, entry) = requirements.iter().find_map(|r| {
2104 let e = entries.get(&r.key)?;
2105 r.scenarios
2106 .iter()
2107 .any(|s| ids.contains(&s.id.as_str()))
2108 .then_some((r, e))
2109 })?;
2110 let digest = req.digest();
2111 if entry.requirement_sha256 != digest {
2112 return Some(TaskReuse::Lapsed {
2113 req: req.key.clone(),
2114 frozen: entry.requirement_sha256.clone(),
2115 current: digest,
2116 });
2117 }
2118 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2119 Ok(Some(x)) => x,
2120 Ok(None) => {
2121 return Some(TaskReuse::Elsewhere {
2122 req: req.key.clone(),
2123 });
2124 }
2125 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2126 };
2127 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2131 let reused = crate::git::Git::at(wt_root)
2132 .run(&[
2133 "log",
2134 "--format=%H",
2135 "--fixed-strings",
2136 &format!("--grep={needle}"),
2137 "HEAD",
2138 ])
2139 .map(|out| !out.is_empty());
2140 match reused {
2141 Ok(true) => {
2142 return Some(TaskReuse::Reused {
2143 req: req.key.clone(),
2144 });
2145 }
2146 Ok(false) => {}
2147 Err(e) => {
2148 return Some(TaskReuse::Unreadable(format!(
2149 "the worktree's history cannot be read: {}",
2150 e.0
2151 )));
2152 }
2153 }
2154 if !record.is_current(&digest) {
2155 return Some(TaskReuse::Lapsed {
2156 req: req.key.clone(),
2157 frozen: record.wording_digest.clone(),
2158 current: digest,
2159 });
2160 }
2161 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2162 return Some(TaskReuse::Tampered(format!(
2163 "{}'s frozen commands are missing from the state directory",
2164 req.key
2165 )));
2166 };
2167 let manifest = manifest_digest(
2168 frozen
2169 .tests
2170 .iter()
2171 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2172 );
2173 let same_files = frozen.tests.len() == record.visible.len()
2174 && frozen.tests.iter().all(|t| {
2175 record
2176 .visible
2177 .iter()
2178 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2179 });
2180 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2181 return Some(TaskReuse::Tampered(format!(
2182 "{}'s frozen tests no longer match the manifest review.json names",
2183 req.key
2184 )));
2185 }
2186 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2189 return Some(TaskReuse::Tampered(format!(
2190 "{}'s frozen set names another approval than review.json{}",
2191 req.key,
2192 if approval.is_none() {
2193 " (which records none)"
2194 } else {
2195 ""
2196 }
2197 )));
2198 }
2199 let sealed = record
2200 .held_out_sealed
2201 .as_ref()
2202 .map(|h| format!("sha256:{}", h.manifest_sha256));
2203 if sealed != entry.held_out_sha256 {
2204 return Some(TaskReuse::Tampered(format!(
2205 "{}'s frozen held-out set is not the one review.json names",
2206 req.key
2207 )));
2208 }
2209 let tests = frozen
2210 .tests
2211 .iter()
2212 .map(|t| crate::pipeline::ReusedTest {
2213 path: t.path.clone(),
2214 cites: t.scenarios.clone(),
2215 argv: t.argv.clone(),
2216 green: false,
2217 })
2218 .collect();
2219 Some(TaskReuse::Ready {
2220 record: Box::new(record),
2221 files,
2222 tests,
2223 manifest,
2224 })
2225}
2226
2227pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2230 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2231 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2232 .ok()
2233 .and_then(|t| serde_json::from_str(&t).ok())
2234 .unwrap_or_default();
2235 let key = format!("{req}={frozen}");
2236 if seen.contains(&key) {
2237 return false;
2238 }
2239 seen.push(key);
2240 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2241 true
2242}
2243
2244#[must_use]
2246pub fn autopilot_denials(
2247 task: &str,
2248 tool_calls: &[Value],
2249) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2250 tool_calls
2251 .iter()
2252 .enumerate()
2253 .filter(|(_, c)| {
2254 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2255 })
2256 .map(|(i, c)| {
2257 let object: Option<tools::policy::Object> =
2258 serde_json::from_value(c["object"].clone()).ok();
2259 speccoder_core::oracle::AutopilotDenial {
2260 task: task.to_owned(),
2261 turn: c["turn"]
2262 .as_u64()
2263 .and_then(|n| u32::try_from(n).ok())
2264 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2265 class: object
2266 .as_ref()
2267 .map(|o| o.class().as_str().to_owned())
2268 .unwrap_or_default(),
2269 object: object.as_ref().map_or_else(
2270 || {
2271 c["arguments"]["path"]
2272 .as_str()
2273 .map(str::to_owned)
2274 .or_else(|| {
2275 c["arguments"]["argv"].as_array().map(|a| {
2276 a.iter()
2277 .filter_map(Value::as_str)
2278 .collect::<Vec<_>>()
2279 .join(" ")
2280 })
2281 })
2282 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2283 },
2284 tools::policy::Object::describe,
2285 ),
2286 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2287 }
2288 })
2289 .collect()
2290}
2291
2292#[must_use]
2294pub fn last_denials(
2295 project_state: &Path,
2296 unit: &str,
2297) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2298 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2299 .ok()
2300 .and_then(|t| serde_json::from_str(&t).ok())
2301 .unwrap_or_default()
2302}
2303
2304pub(crate) fn record_denials(
2306 project_state: &Path,
2307 unit: &str,
2308 denials: &[speccoder_core::oracle::AutopilotDenial],
2309) {
2310 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2311 if denials.is_empty() {
2312 let _ = std::fs::remove_file(file);
2313 return;
2314 }
2315 let _ = write_atomic(
2316 &file,
2317 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2318 );
2319}
2320
2321#[cfg(test)]
2322mod tests {
2323 use super::*;
2324
2325 fn draft(content: &str) -> Draft {
2326 Draft {
2327 requirement: DraftRequirement {
2328 key: "US1".to_owned(),
2329 title: "Greet".to_owned(),
2330 text: "### User Story 1 - Greet".to_owned(),
2331 sha256: "sha256:w".to_owned(),
2332 },
2333 samples: 2,
2334 tests: vec![DraftTest {
2335 path: "tests/acceptance_us1.rs".to_owned(),
2336 sha256: digest_of(content),
2337 scenarios: vec!["US1-1".to_owned()],
2338 argv: vec!["cargo".to_owned(), "test".to_owned()],
2339 red_class: RedClass::Assertion,
2340 authored_by: AuthoredBy::Model,
2341 edited_by: None,
2342 }],
2343 discarded: Vec::new(),
2344 rejected: Vec::new(),
2345 expectations: Samples::default(),
2346 untestable: Samples::default(),
2347 ambiguities: Vec::new(),
2348 held_out: HeldOutSummary {
2349 state: "absent".to_owned(),
2350 count: 0,
2351 manifest_sha256: None,
2352 reason: None,
2353 },
2354 sealed: None,
2355 notes: Vec::new(),
2356 runs: Vec::new(),
2357 context: Vec::new(),
2358 }
2359 }
2360
2361 #[test]
2362 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2363 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2364 let d = draft("#[test] fn t() {}");
2365 let files = vec![(
2366 "tests/acceptance_us1.rs".to_owned(),
2367 "#[test] fn t() {}".to_owned(),
2368 )];
2369 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2370 let (back, contents) = load(state.path(), "001-greet", "US1")
2371 .unwrap_or_else(|e| panic!("{e:?}"))
2372 .unwrap_or_else(|| panic!("stored"));
2373 assert_eq!(back, d);
2374 assert_eq!(contents, files);
2375 std::fs::write(
2376 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2377 "changed",
2378 )
2379 .unwrap_or_else(|e| panic!("{e}"));
2380 assert!(load(state.path(), "001-greet", "US1").is_err());
2381 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2382 assert!(
2383 load(state.path(), "001-greet", "US2")
2384 .unwrap_or_default()
2385 .is_none()
2386 );
2387 }
2388
2389 #[test]
2390 fn the_manifest_names_paths_contents_and_commands() {
2391 let argv = vec!["cargo".to_owned()];
2392 let other = vec!["make".to_owned()];
2393 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2394 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2395 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2396 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2397 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2398 assert_eq!(
2399 both,
2400 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2401 );
2402 let mut d = draft("x");
2403 assert!(d.manifest().is_some());
2404 d.tests[0].red_class = RedClass::Passed;
2405 assert!(d.manifest().is_none());
2406 assert_eq!(slug("cap: a / b"), "cap__a___b");
2407 }
2408
2409 #[test]
2410 fn a_lapse_is_noted_once() {
2411 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2412 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
2413 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
2414 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
2415 }
2416}