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 outcome.status.as_str()
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 )
1247 })?;
1248 }
1249 Ok(Self {
1250 scratch,
1251 project,
1252 runner,
1253 tmp,
1254 })
1255 }
1256
1257 fn files(&self) -> (Vec<String>, String) {
1258 let files: Vec<String> = worktree::checkpoint(&self.scratch.path, &self.tmp)
1259 .and_then(|c| worktree::files(&self.scratch.path, &c))
1260 .unwrap_or_default()
1261 .into_iter()
1262 .take(400)
1263 .collect();
1264 let manifest = crate::pipeline::MANIFESTS
1265 .iter()
1266 .find_map(|m| {
1267 std::fs::read_to_string(self.project.join(m))
1268 .ok()
1269 .map(|t| format!("{m}:\n{t}"))
1270 })
1271 .map(|t| t.chars().take(8192).collect::<String>())
1272 .unwrap_or_default();
1273 (files, manifest)
1274 }
1275
1276 fn red(
1282 &self,
1283 tests: &[(String, String, Vec<String>)],
1284 ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1285 let mut out = Vec::new();
1286 for (path, content, argv) in tests {
1287 let full = self.project.join(path);
1288 let _ = std::fs::remove_file(&full);
1289 let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1290 let (bare, summary) = run();
1291 if classify_red(&bare, summary.as_ref()).is_red() {
1292 out.push(Err("red_without_it"));
1293 continue;
1294 }
1295 if let Some(parent) = full.parent() {
1296 std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1297 }
1298 std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1299 let (first, summary) = run();
1300 let class = classify_red(&first, summary.as_ref());
1301 let class = if !class.is_red() {
1302 class
1303 } else {
1304 let (second, summary) = run();
1305 if classify_red(&second, summary.as_ref()).is_red() {
1306 class
1307 } else {
1308 RedClass::Flaky
1309 }
1310 };
1311 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1312 out.push(Ok(class));
1313 }
1314 Ok(out)
1315 }
1316}
1317
1318fn keep(
1320 ctx: &Ctx,
1321 bench: &Bench,
1322 answer: &Value,
1323 req: &OracleRequirement,
1324 taken: &[(String, Vec<String>)],
1325) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1326 let (valid, refused) =
1327 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios);
1328 let mut discarded: Vec<OracleDiscarded> = refused
1329 .into_iter()
1330 .map(|r| OracleDiscarded {
1331 path: r.path,
1332 why: r.why.to_owned(),
1333 })
1334 .collect();
1335 let set_argv: Vec<String> = answer["argv"]
1336 .as_array()
1337 .into_iter()
1338 .flatten()
1339 .filter_map(|v| v.as_str().map(str::to_owned))
1340 .collect();
1341 let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1342 let mut kept = Vec::new();
1343 for f in valid {
1344 let mut discard = |why: &str| {
1345 discarded.push(OracleDiscarded {
1346 path: f.path.clone(),
1347 why: why.to_owned(),
1348 });
1349 };
1350 if f.cited.is_empty() {
1351 discard("cites_no_scenario");
1352 continue;
1353 }
1354 if taken.iter().any(|(p, _)| *p == f.path)
1355 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1356 {
1357 discard("exists");
1358 continue;
1359 }
1360 let argv = if f.argv.is_empty() {
1361 set_argv.clone()
1362 } else {
1363 f.argv.clone()
1364 };
1365 if taken.iter().any(|(_, a)| *a == argv)
1368 || kept
1369 .iter()
1370 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1371 {
1372 discard("command_shared");
1373 continue;
1374 }
1375 if argv.is_empty()
1376 || !crate::pipeline::test_command_allowed(
1377 &ctx.policy,
1378 Some(&layout),
1379 &argv,
1380 &[f.path.as_str()],
1381 )
1382 {
1383 discard("command_not_allowed");
1384 continue;
1385 }
1386 if kept.len() + taken.len() >= LIMIT {
1387 discard("limit");
1388 continue;
1389 }
1390 kept.push((f.path, f.content, f.cited, argv));
1391 }
1392 (kept, discarded)
1393}
1394
1395fn write(
1398 ctx: &Ctx,
1399 req: &OracleRequirement,
1400 provider: Option<Arc<dyn ModelProvider>>,
1401 options: &crate::RunOptions,
1402) -> Outcome {
1403 let samples = ctx.config.oracle_samples.clamp(1, 2);
1404 let author = Author::open(ctx, req, provider, options)?;
1405 let bench = Bench::open(ctx)?;
1406 let (files, manifest) = bench.files();
1407 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1410 Box::new(
1411 refuse(
1412 CommandStatus::NeedsHuman,
1413 "ORACLE-TAMPERED",
1414 &format!(
1415 "{}: writing anew would lose the rejections and notes it holds",
1416 t.0
1417 ),
1418 )
1419 .with_fix(vec![format!(
1420 "→ restore it, or remove {} to start {}'s tests over",
1421 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1422 req.key
1423 )]),
1424 )
1425 })?;
1426 let (rejected, mut notes) = earlier
1427 .as_ref()
1428 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1429 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1430 .unwrap_or_default();
1431 notes.dedup();
1432 let told: Vec<String> = rejected
1433 .iter()
1434 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1435 .chain(notes.iter().cloned())
1436 .collect();
1437 let of = u32::from(samples);
1438 stage(options, &req.key, "writing", 1, of);
1439 let (first, run1, mut context) = author
1440 .call(req, &files, &manifest, &told, None, 1)
1441 .map_err(failed)?;
1442 let read = |answer: &Value| {
1445 let got = expectations(answer, req);
1446 if samples >= 2 {
1447 got.map_err(failed)
1448 } else {
1449 Ok(got.unwrap_or_default())
1450 }
1451 };
1452 let (e1, u1) = read(&first)?;
1453 let mut runs = vec![run1];
1454 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1455 if samples >= 2 {
1456 stage(options, &req.key, "writing", 2, of);
1457 let (second, run2, more) = author
1458 .call(req, &files, &manifest, &told, None, 2)
1459 .map_err(failed)?;
1460 (e2, u2) = read(&second)?;
1461 runs.push(run2);
1462 for c in more {
1463 if !context.contains(&c) {
1464 context.push(c);
1465 }
1466 }
1467 stage(options, &req.key, "comparing", 1, 1);
1468 }
1469 let ambiguities = if samples >= 2 {
1470 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1471 } else {
1472 Vec::new()
1473 };
1474 let (kept, mut discarded) = keep(ctx, &bench, &first, req, &[]);
1475 stage(options, &req.key, "checking_red", 1, 1);
1476 let plan: Vec<(String, String, Vec<String>)> = kept
1477 .iter()
1478 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1479 .collect();
1480 let classes = bench.red(&plan).map_err(failed)?;
1481 let mut tests: Vec<DraftTest> = Vec::new();
1482 let mut contents: Vec<(String, String)> = Vec::new();
1483 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1484 match class {
1485 Ok(red_class) => {
1486 tests.push(DraftTest {
1487 path: path.clone(),
1488 sha256: digest_of(&content),
1489 scenarios: cited,
1490 argv,
1491 red_class,
1492 authored_by: AuthoredBy::Model,
1493 edited_by: None,
1494 });
1495 contents.push((path, content));
1496 }
1497 Err(why) => discarded.push(OracleDiscarded {
1498 path,
1499 why: why.to_owned(),
1500 }),
1501 }
1502 }
1503 let (held_out, sealed) = held_out(ctx, &author, &bench, req, &tests, options);
1504 let draft = Draft {
1505 requirement: DraftRequirement {
1506 key: req.key.clone(),
1507 title: req.title.clone(),
1508 text: req.text.clone(),
1509 sha256: req.digest(),
1510 },
1511 samples,
1512 tests,
1513 discarded,
1514 rejected,
1515 expectations: Samples {
1516 sample1: e1,
1517 sample2: e2,
1518 },
1519 untestable: Samples {
1520 sample1: u1,
1521 sample2: u2,
1522 },
1523 ambiguities,
1524 held_out,
1525 sealed,
1526 notes,
1527 runs,
1528 context,
1529 };
1530 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1531 let red = draft.red().count();
1532 let open = draft.open_ambiguities().count();
1533 Ok(format!(
1534 "wrote {} test(s) for {}: {red} red{}{}",
1535 draft.tests.len(),
1536 req.key,
1537 if draft.held_out.state == "kept" {
1538 format!(", {} held out", draft.held_out.count)
1539 } else {
1540 String::new()
1541 },
1542 if open > 0 {
1543 format!(
1544 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1545 req.key, req.key
1546 )
1547 } else {
1548 String::new()
1549 }
1550 ))
1551}
1552
1553fn held_out(
1557 ctx: &Ctx,
1558 author: &Author<'_>,
1559 bench: &Bench,
1560 req: &OracleRequirement,
1561 tests: &[DraftTest],
1562 options: &crate::RunOptions,
1563) -> (HeldOutSummary, Option<SealedRef>) {
1564 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1565 state: state.to_owned(),
1566 count: 0,
1567 manifest_sha256: None,
1568 reason: reason.map(str::to_owned),
1569 };
1570 if !ctx.config.held_out {
1571 return (summary("off", None), None);
1572 }
1573 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1574 if red.is_empty() {
1575 return (summary("absent", Some("not_red")), None);
1576 }
1577 stage(options, &req.key, "held_out", 1, 1);
1578 let visible = crate::pipeline::PreparedAcceptance {
1579 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1580 argv: red.first().map(|t| t.argv.clone()),
1581 more_argv: Vec::new(),
1582 red: true,
1583 red_class: Some(RedClass::Assertion),
1584 held_out: None,
1585 generation_failed: None,
1586 skip_cause: None,
1587 };
1588 let task = format!("req-{}", req.key);
1589 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1590 let (report, sha) = crate::pipeline::prepare_held_out(
1591 &crate::pipeline::AcceptanceSetup {
1592 rt: &author.rt,
1593 plane: &author.plane,
1594 project: &bench.project,
1595 worktree: &bench.scratch.path,
1596 tmp: &bench.tmp,
1597 policy: &ctx.policy,
1598 feature: &ctx.feature,
1599 task: &task,
1600 bundle: author.bundle.clone(),
1601 scenarios: &req.scenarios,
1602 instructions: &author.instructions,
1603 runner: &bench.runner,
1604 layout: None,
1605 story_files: &[],
1606 },
1607 &visible,
1608 &req.scenarios,
1609 &store,
1610 false,
1611 );
1612 match sha {
1613 Some(sha) => {
1614 let count = report["kept"]
1615 .as_u64()
1616 .and_then(|n| usize::try_from(n).ok())
1617 .unwrap_or(0);
1618 (
1619 HeldOutSummary {
1620 state: "kept".to_owned(),
1621 count,
1622 manifest_sha256: Some(format!("sha256:{sha}")),
1623 reason: None,
1624 },
1625 Some(SealedRef {
1626 key: req.key.clone(),
1627 manifest_sha256: sha,
1628 count,
1629 }),
1630 )
1631 }
1632 None => (
1633 summary(
1634 "absent",
1635 Some(report["reason"].as_str().unwrap_or("none_kept")),
1636 ),
1637 None,
1638 ),
1639 }
1640}
1641
1642fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1643 let Some(path) = r.path else {
1644 return Err(usage("name the test to reject (--reject PATH)"));
1645 };
1646 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1647 return Err(usage(
1648 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1649 ));
1650 };
1651 let (mut d, contents) = existing(ctx, req)?;
1652 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1653 return Err(usage(&format!("{} has no test {path}", req.key)));
1654 };
1655 d.tests.remove(at);
1656 d.rejected.push(OracleRejected {
1657 path: path.to_owned(),
1658 reason: reason.to_owned(),
1659 by: by.to_owned(),
1660 at: now(),
1661 });
1662 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1663 Ok(format!(
1664 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1665 req.key, req.key
1666 ))
1667}
1668
1669fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1670 let Some(path) = r.path else {
1671 return Err(usage("name the test to edit (--edit PATH)"));
1672 };
1673 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1674 return Err(usage("an edit needs the test's new content (--from FILE)"));
1675 };
1676 let (mut d, mut contents) = existing(ctx, req)?;
1677 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1678 return Err(usage(&format!("{} has no test {path}", req.key)));
1679 };
1680 let bench = Bench::open(ctx)?;
1681 let plan = [(
1682 path.to_owned(),
1683 content.to_owned(),
1684 d.tests[at].argv.clone(),
1685 )];
1686 let checked = bench
1687 .red(&plan)
1688 .map_err(failed)?
1689 .pop()
1690 .unwrap_or(Ok(RedClass::Unclassified));
1691 let class = checked.unwrap_or(RedClass::Unclassified);
1693 let t = &mut d.tests[at];
1694 t.sha256 = digest_of(content);
1695 t.red_class = class;
1696 t.authored_by = AuthoredBy::Person;
1697 t.edited_by = Some(by.to_owned());
1698 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1699 c.1 = content.to_owned();
1700 }
1701 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1702 Ok(format!(
1703 "edited {path} of {}: {}",
1704 req.key,
1705 if class.is_red() {
1706 format!("red ({})", crate::pipeline::class_word(class))
1707 } else if checked.is_err() {
1708 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1709 } else {
1710 format!(
1711 "not red ({}) — no evidence, it cannot be frozen",
1712 crate::pipeline::class_word(class)
1713 )
1714 }
1715 ))
1716}
1717
1718#[allow(clippy::too_many_arguments)]
1721fn rewrite(
1722 ctx: &Ctx,
1723 req: &OracleRequirement,
1724 mut d: Draft,
1725 mut contents: Vec<(String, String)>,
1726 replaced: &[String],
1727 note: String,
1728 again: Option<(&str, &str)>,
1729 only: Option<&str>,
1730 provider: Option<Arc<dyn ModelProvider>>,
1731 options: &crate::RunOptions,
1732) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1733 let author = Author::open(ctx, req, provider, options)?;
1734 let bench = Bench::open(ctx)?;
1735 let (files, manifest) = bench.files();
1736 d.notes.push(note);
1737 let told = d.all_notes();
1738 stage(options, &req.key, "writing", 1, 1);
1739 let (answer, run, context) = author
1740 .call(req, &files, &manifest, &told, again, 1)
1741 .map_err(failed)?;
1742 d.runs.push(run);
1743 for c in context {
1744 if !d.context.contains(&c) {
1745 d.context.push(c);
1746 }
1747 }
1748 let keep_paths: Vec<(String, Vec<String>)> = d
1749 .tests
1750 .iter()
1751 .filter(|t| !replaced.contains(&t.path))
1752 .map(|t| (t.path.clone(), t.argv.clone()))
1753 .collect();
1754 let mut answer = answer;
1756 if let Some(files) = answer["files"].as_array_mut() {
1757 files.retain(|f| {
1758 only.is_none_or(|s| {
1759 f["scenarios"]
1760 .as_array()
1761 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1762 })
1763 });
1764 }
1765 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1766 if new.is_empty() {
1767 return Err(failed(format!(
1768 "the test author wrote no usable test{}",
1769 discarded
1770 .first()
1771 .map(|x| format!(" ({} {})", x.path, x.why))
1772 .unwrap_or_default()
1773 )));
1774 }
1775 d.tests.retain(|t| !replaced.contains(&t.path));
1776 contents.retain(|(p, _)| !replaced.contains(p));
1777 d.discarded.extend(discarded);
1778 stage(options, &req.key, "checking_red", 1, 1);
1779 let plan: Vec<(String, String, Vec<String>)> = new
1781 .iter()
1782 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1783 .collect();
1784 let classes = bench.red(&plan).map_err(failed)?;
1785 let mut written = Vec::new();
1786 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1787 let red_class = match class {
1788 Ok(c) => c,
1789 Err(why) => {
1790 d.discarded.push(OracleDiscarded {
1791 path,
1792 why: why.to_owned(),
1793 });
1794 continue;
1795 }
1796 };
1797 d.tests.push(DraftTest {
1798 sha256: digest_of(&content),
1799 scenarios: cited,
1800 argv,
1801 red_class,
1802 authored_by: AuthoredBy::Model,
1803 edited_by: None,
1804 path: path.clone(),
1805 });
1806 contents.push((path.clone(), content));
1807 written.push(path);
1808 }
1809 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1810 Ok((d, written))
1811}
1812
1813fn again(
1814 ctx: &Ctx,
1815 req: &OracleRequirement,
1816 r: &Request<'_>,
1817 _by: &str,
1818 provider: Option<Arc<dyn ModelProvider>>,
1819 options: &crate::RunOptions,
1820) -> Outcome {
1821 let Some(path) = r.path else {
1822 return Err(usage("name the test to write again (--again PATH)"));
1823 };
1824 let comment = r
1825 .text
1826 .map(str::trim)
1827 .filter(|t| !t.is_empty())
1828 .unwrap_or("write it again");
1829 let (d, contents) = existing(ctx, req)?;
1830 let Some(content) = contents
1831 .iter()
1832 .find(|(p, _)| p == path)
1833 .map(|(_, c)| c.clone())
1834 else {
1835 return Err(usage(&format!("{} has no test {path}", req.key)));
1836 };
1837 let (d, written) = rewrite(
1838 ctx,
1839 req,
1840 d,
1841 contents,
1842 &[path.to_owned()],
1843 format!("Comment on `{path}`: {comment}"),
1844 Some((path, &content)),
1845 None,
1846 provider,
1847 options,
1848 )?;
1849 let red = d
1850 .tests
1851 .iter()
1852 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
1853 .count();
1854 Ok(format!(
1855 "wrote {path} of {} again: {}",
1856 req.key,
1857 if written.is_empty() {
1858 "nothing kept — its command is red without it, so it decides nothing".to_owned()
1859 } else if red == written.len() {
1860 "red".to_owned()
1861 } else {
1862 "not red — no evidence, it cannot be frozen".to_owned()
1863 }
1864 ))
1865}
1866
1867fn reading(
1868 ctx: &Ctx,
1869 req: &OracleRequirement,
1870 r: &Request<'_>,
1871 by: &str,
1872 provider: Option<Arc<dyn ModelProvider>>,
1873 options: &crate::RunOptions,
1874) -> Outcome {
1875 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
1876 return Err(usage(
1877 "name the reading and the scenario (--reading A|B US1-2)",
1878 ));
1879 };
1880 let (mut d, contents) = existing(ctx, req)?;
1881 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
1882 return Err(usage(&format!(
1883 "{} has no ambiguity on {scenario}",
1884 req.key
1885 )));
1886 };
1887 item.resolution = Some(Resolution {
1888 reading: choice,
1889 by: by.to_owned(),
1890 at: now(),
1891 });
1892 let meant = if choice == Reading::A {
1893 item.a.clone()
1894 } else {
1895 item.b.clone()
1896 };
1897 if choice == Reading::A {
1898 d.notes.push(format!(
1899 "For {scenario} the person means reading A: given {}, expect {}",
1900 meant.given, meant.expect
1901 ));
1902 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1903 return Ok(format!(
1904 "{scenario} of {}: reading A is meant — the tests stay",
1905 req.key
1906 ));
1907 }
1908 let replaced: Vec<String> = d
1909 .tests
1910 .iter()
1911 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
1912 .map(|t| t.path.clone())
1913 .collect();
1914 let (_, written) = rewrite(
1915 ctx,
1916 req,
1917 d,
1918 contents,
1919 &replaced,
1920 format!(
1921 "For {scenario} the person means reading B: given {}, expect {}",
1922 meant.given, meant.expect
1923 ),
1924 None,
1925 Some(scenario),
1926 provider,
1927 options,
1928 )?;
1929 Ok(format!(
1930 "{scenario} of {}: reading B is meant — wrote {} anew",
1931 req.key,
1932 written.join(", ")
1933 ))
1934}
1935
1936#[derive(Debug, Clone)]
1939pub struct Freezable {
1940 pub req: OracleRequirement,
1942 pub draft: Draft,
1944 pub contents: Vec<(String, String)>,
1946 pub manifest: String,
1948}
1949
1950pub fn freezable(
1953 project_state: &Path,
1954 unit: &str,
1955 requirements: &[OracleRequirement],
1956) -> Result<Vec<Freezable>, Tampered> {
1957 let mut out = Vec::new();
1958 for r in requirements {
1959 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
1960 continue;
1961 };
1962 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
1963 continue;
1964 }
1965 let Some(manifest) = draft.manifest() else {
1966 continue;
1967 };
1968 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
1969 out.push(Freezable {
1970 req: r.clone(),
1971 contents: contents
1972 .into_iter()
1973 .filter(|(p, _)| red.contains(p))
1974 .collect(),
1975 draft,
1976 manifest,
1977 });
1978 }
1979 Ok(out)
1980}
1981
1982pub fn freeze(
1986 project_state: &Path,
1987 unit: &str,
1988 f: &Freezable,
1989 approval_digest: &str,
1990 decided: Decided,
1991) -> Result<PreFrozenAcceptance, String> {
1992 let sealed = match &f.draft.sealed {
1993 Some(s) => {
1994 crate::held_out::open_sealed(project_state, unit, s)
1995 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
1996 Some(s.clone())
1997 }
1998 None => None,
1999 };
2000 let red: Vec<&DraftTest> = f.draft.red().collect();
2001 let record = PreFrozenAcceptance {
2002 unit: unit.to_owned(),
2003 requirement: f.req.key.clone(),
2004 wording_digest: f.draft.requirement.sha256.clone(),
2005 approval_digest: approval_digest.to_owned(),
2006 visible: red
2007 .iter()
2008 .map(|t| VisibleTest {
2009 path: t.path.clone(),
2010 sha256: t.sha256.clone(),
2011 cites: t.scenarios.clone(),
2012 })
2013 .collect(),
2014 held_out_sealed: sealed,
2015 producer: Producer::Review,
2016 decided,
2017 };
2018 crate::pre_frozen::store(project_state, &record, &f.contents)?;
2019 let frozen = Frozen {
2020 requirement_sha256: f.draft.requirement.sha256.clone(),
2021 wording: format!(
2022 "{}\n{}",
2023 f.draft.requirement.title, f.draft.requirement.text
2024 ),
2025 manifest_sha256: f.manifest.clone(),
2026 tests: red
2027 .iter()
2028 .map(|t| FrozenTest {
2029 path: t.path.clone(),
2030 sha256: t.sha256.clone(),
2031 argv: t.argv.clone(),
2032 scenarios: t.scenarios.clone(),
2033 })
2034 .collect(),
2035 };
2036 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2037 write_atomic(
2038 &dir(project_state, unit, &f.req.key).join("frozen.json"),
2039 &text,
2040 )?;
2041 Ok(record)
2042}
2043
2044#[derive(Debug)]
2046pub(crate) enum TaskReuse {
2047 Ready {
2049 record: Box<PreFrozenAcceptance>,
2051 files: Vec<(String, String)>,
2053 tests: Vec<crate::pipeline::ReusedTest>,
2055 manifest: String,
2057 },
2058 Reused {
2060 req: String,
2062 },
2063 Elsewhere {
2065 req: String,
2067 },
2068 Lapsed {
2070 req: String,
2072 frozen: String,
2074 current: String,
2076 },
2077 Tampered(String),
2079 Unreadable(String),
2081}
2082
2083pub(crate) fn for_task(
2088 project_state: &Path,
2089 project: &Path,
2090 wt_root: &Path,
2091 format: &dyn Adapter,
2092 snapshot: &ProjectSnapshot,
2093 scenarios: &[speccoder_core::spec::ScenarioRef],
2094) -> Option<TaskReuse> {
2095 let f = snapshot.feature.as_ref()?;
2096 let unit = f.feature.name.as_str();
2097 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2098 Ok(x) => x,
2099 Err(e) => return Some(TaskReuse::Unreadable(e)),
2101 };
2102 if entries.is_empty() {
2103 return None;
2104 }
2105 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2106 let requirements = format.oracle_requirements(snapshot);
2107 let (req, entry) = requirements.iter().find_map(|r| {
2108 let e = entries.get(&r.key)?;
2109 r.scenarios
2110 .iter()
2111 .any(|s| ids.contains(&s.id.as_str()))
2112 .then_some((r, e))
2113 })?;
2114 let digest = req.digest();
2115 if entry.requirement_sha256 != digest {
2116 return Some(TaskReuse::Lapsed {
2117 req: req.key.clone(),
2118 frozen: entry.requirement_sha256.clone(),
2119 current: digest,
2120 });
2121 }
2122 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2123 Ok(Some(x)) => x,
2124 Ok(None) => {
2125 return Some(TaskReuse::Elsewhere {
2126 req: req.key.clone(),
2127 });
2128 }
2129 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2130 };
2131 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2135 let reused = crate::git::Git::at(wt_root)
2136 .run(&[
2137 "log",
2138 "--format=%H",
2139 "--fixed-strings",
2140 &format!("--grep={needle}"),
2141 "HEAD",
2142 ])
2143 .map(|out| !out.is_empty());
2144 match reused {
2145 Ok(true) => {
2146 return Some(TaskReuse::Reused {
2147 req: req.key.clone(),
2148 });
2149 }
2150 Ok(false) => {}
2151 Err(e) => {
2152 return Some(TaskReuse::Unreadable(format!(
2153 "the worktree's history cannot be read: {}",
2154 e.0
2155 )));
2156 }
2157 }
2158 if !record.is_current(&digest) {
2159 return Some(TaskReuse::Lapsed {
2160 req: req.key.clone(),
2161 frozen: record.wording_digest.clone(),
2162 current: digest,
2163 });
2164 }
2165 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2166 return Some(TaskReuse::Tampered(format!(
2167 "{}'s frozen commands are missing from the state directory",
2168 req.key
2169 )));
2170 };
2171 let manifest = manifest_digest(
2172 frozen
2173 .tests
2174 .iter()
2175 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2176 );
2177 let same_files = frozen.tests.len() == record.visible.len()
2178 && frozen.tests.iter().all(|t| {
2179 record
2180 .visible
2181 .iter()
2182 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2183 });
2184 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2185 return Some(TaskReuse::Tampered(format!(
2186 "{}'s frozen tests no longer match the manifest review.json names",
2187 req.key
2188 )));
2189 }
2190 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2193 return Some(TaskReuse::Tampered(format!(
2194 "{}'s frozen set names another approval than review.json{}",
2195 req.key,
2196 if approval.is_none() {
2197 " (which records none)"
2198 } else {
2199 ""
2200 }
2201 )));
2202 }
2203 let sealed = record
2204 .held_out_sealed
2205 .as_ref()
2206 .map(|h| format!("sha256:{}", h.manifest_sha256));
2207 if sealed != entry.held_out_sha256 {
2208 return Some(TaskReuse::Tampered(format!(
2209 "{}'s frozen held-out set is not the one review.json names",
2210 req.key
2211 )));
2212 }
2213 let tests = frozen
2214 .tests
2215 .iter()
2216 .map(|t| crate::pipeline::ReusedTest {
2217 path: t.path.clone(),
2218 cites: t.scenarios.clone(),
2219 argv: t.argv.clone(),
2220 green: false,
2221 })
2222 .collect();
2223 Some(TaskReuse::Ready {
2224 record: Box::new(record),
2225 files,
2226 tests,
2227 manifest,
2228 })
2229}
2230
2231pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2234 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2235 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2236 .ok()
2237 .and_then(|t| serde_json::from_str(&t).ok())
2238 .unwrap_or_default();
2239 let key = format!("{req}={frozen}");
2240 if seen.contains(&key) {
2241 return false;
2242 }
2243 seen.push(key);
2244 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2245 true
2246}
2247
2248#[must_use]
2250pub fn autopilot_denials(
2251 task: &str,
2252 tool_calls: &[Value],
2253) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2254 tool_calls
2255 .iter()
2256 .enumerate()
2257 .filter(|(_, c)| {
2258 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2259 })
2260 .map(|(i, c)| {
2261 let object: Option<tools::policy::Object> =
2262 serde_json::from_value(c["object"].clone()).ok();
2263 speccoder_core::oracle::AutopilotDenial {
2264 task: task.to_owned(),
2265 turn: c["turn"]
2266 .as_u64()
2267 .and_then(|n| u32::try_from(n).ok())
2268 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2269 class: object
2270 .as_ref()
2271 .map(|o| o.class().as_str().to_owned())
2272 .unwrap_or_default(),
2273 object: object.as_ref().map_or_else(
2274 || {
2275 c["arguments"]["path"]
2276 .as_str()
2277 .map(str::to_owned)
2278 .or_else(|| {
2279 c["arguments"]["argv"].as_array().map(|a| {
2280 a.iter()
2281 .filter_map(Value::as_str)
2282 .collect::<Vec<_>>()
2283 .join(" ")
2284 })
2285 })
2286 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2287 },
2288 tools::policy::Object::describe,
2289 ),
2290 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2291 }
2292 })
2293 .collect()
2294}
2295
2296#[must_use]
2298pub fn last_denials(
2299 project_state: &Path,
2300 unit: &str,
2301) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2302 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2303 .ok()
2304 .and_then(|t| serde_json::from_str(&t).ok())
2305 .unwrap_or_default()
2306}
2307
2308pub(crate) fn record_denials(
2310 project_state: &Path,
2311 unit: &str,
2312 denials: &[speccoder_core::oracle::AutopilotDenial],
2313) {
2314 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2315 if denials.is_empty() {
2316 let _ = std::fs::remove_file(file);
2317 return;
2318 }
2319 let _ = write_atomic(
2320 &file,
2321 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2322 );
2323}
2324
2325#[cfg(test)]
2326mod tests {
2327 use super::*;
2328
2329 fn draft(content: &str) -> Draft {
2330 Draft {
2331 requirement: DraftRequirement {
2332 key: "US1".to_owned(),
2333 title: "Greet".to_owned(),
2334 text: "### User Story 1 - Greet".to_owned(),
2335 sha256: "sha256:w".to_owned(),
2336 },
2337 samples: 2,
2338 tests: vec![DraftTest {
2339 path: "tests/acceptance_us1.rs".to_owned(),
2340 sha256: digest_of(content),
2341 scenarios: vec!["US1-1".to_owned()],
2342 argv: vec!["cargo".to_owned(), "test".to_owned()],
2343 red_class: RedClass::Assertion,
2344 authored_by: AuthoredBy::Model,
2345 edited_by: None,
2346 }],
2347 discarded: Vec::new(),
2348 rejected: Vec::new(),
2349 expectations: Samples::default(),
2350 untestable: Samples::default(),
2351 ambiguities: Vec::new(),
2352 held_out: HeldOutSummary {
2353 state: "absent".to_owned(),
2354 count: 0,
2355 manifest_sha256: None,
2356 reason: None,
2357 },
2358 sealed: None,
2359 notes: Vec::new(),
2360 runs: Vec::new(),
2361 context: Vec::new(),
2362 }
2363 }
2364
2365 #[test]
2366 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2367 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2368 let d = draft("#[test] fn t() {}");
2369 let files = vec![(
2370 "tests/acceptance_us1.rs".to_owned(),
2371 "#[test] fn t() {}".to_owned(),
2372 )];
2373 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2374 let (back, contents) = load(state.path(), "001-greet", "US1")
2375 .unwrap_or_else(|e| panic!("{e:?}"))
2376 .unwrap_or_else(|| panic!("stored"));
2377 assert_eq!(back, d);
2378 assert_eq!(contents, files);
2379 std::fs::write(
2380 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2381 "changed",
2382 )
2383 .unwrap_or_else(|e| panic!("{e}"));
2384 assert!(load(state.path(), "001-greet", "US1").is_err());
2385 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2386 assert!(
2387 load(state.path(), "001-greet", "US2")
2388 .unwrap_or_default()
2389 .is_none()
2390 );
2391 }
2392
2393 #[test]
2394 fn the_manifest_names_paths_contents_and_commands() {
2395 let argv = vec!["cargo".to_owned()];
2396 let other = vec!["make".to_owned()];
2397 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2398 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2399 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2400 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2401 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2402 assert_eq!(
2403 both,
2404 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2405 );
2406 let mut d = draft("x");
2407 assert!(d.manifest().is_some());
2408 d.tests[0].red_class = RedClass::Passed;
2409 assert!(d.manifest().is_none());
2410 assert_eq!(slug("cap: a / b"), "cap__a___b");
2411 }
2412
2413 #[test]
2414 fn a_lapse_is_noted_once() {
2415 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2416 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
2417 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
2418 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
2419 }
2420}