use std::path::Path;
use std::sync::Arc;
use speccoder_core::stage::{Stage, StageState};
use speccoder_core::{CommandData, CommandResult, CommandStatus, RunReport};
use super::Carried;
use super::open::Opened;
use super::ready::Ready;
use crate::pipeline::*;
use crate::{attempts, git, progress, verify, worktree};
pub(super) struct TestsFirst {
pub(super) bug_fix: Option<super::super::bug::Fix>,
pub(super) bug_candidate: Option<(String, speccoder_core::bug::Candidate)>,
pub(super) test_layout: crate::layout::TestLayout,
pub(super) test_layout_text: String,
pub(super) story_scenarios: Vec<speccoder_core::spec::ScenarioRef>,
pub(super) story_key: Option<String>,
pub(super) judged_by: Option<speccoder_core::story::Judged>,
pub(super) in_story: bool,
pub(super) scenarios: Vec<speccoder_core::spec::ScenarioRef>,
pub(super) review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)>,
pub(super) story_oracle: Option<super::super::story::StoryOracle>,
pub(super) oracle_note: serde_json::Value,
pub(super) oracle_empty: bool,
pub(super) before_run: super::super::story::Run,
pub(super) oracle_spent: Option<serde_json::Value>,
pub(super) ruled: Vec<crate::acceptance_at::Ruling>,
pub(super) related: Vec<String>,
pub(super) told: Vec<String>,
pub(super) acceptance: Option<PreparedAcceptance>,
pub(super) held_out_store: std::path::PathBuf,
}
pub(super) fn bug_ctx<'a>(
o: &'a Opened,
r: &'a Ready,
task: &'a str,
) -> super::super::bug::Ctx<'a> {
super::super::bug::Ctx {
rt: &r.rt,
plane: &r.plane,
project: &o.project,
wt_root: &o.wt_root,
wt_path: &o.wt_path,
tmp: &o.tmp,
policy: &o.policy,
runner: &o.runner,
state: &o.s.plane_dir,
feature: &o.feature_name,
task,
bundle: &r.bundle_value,
instructions: &r.instructions,
live: &*o.live,
approver: o.approver.as_deref(),
held_out: o.run_config.held_out,
max_tries: o.run_config.reproduce_tries,
bug_test: o.run_config.bug_test.as_deref(),
start: &r.start,
}
}
#[allow(clippy::too_many_lines)] pub(super) fn first(
o: &Opened,
r: &Ready,
carried: &mut Carried,
task: &str,
state: &Path,
secret: Option<&str>,
options: &RunOptions,
) -> Result<TestsFirst, Box<CommandResult>> {
let Opened {
s,
feature_name,
live,
wt_path,
project,
wt_root,
tmp,
interrupted,
format,
snap,
parsed_task,
bundle,
acceptance_story,
story_task,
run_config,
runner,
check_argvs,
unit_dir,
planning,
policy,
..
} = o;
let (format, live) = (*format, Arc::clone(live));
let Ready {
rt,
bundle_value,
instructions,
plane,
demand_red,
start,
..
} = r;
let Carried {
head_now,
set_aside,
baseline,
notices,
..
} = carried;
let fail = |status: CommandStatus, m: String| super::ended(&*live, task, secret, status, m);
let began = std::time::Instant::now();
let bug_ctx = bug_ctx(o, r, task);
let bug_fix = match super::bug::phase(&s.plane_dir, feature_name, task, project, format) {
Err(e) => return Err(Box::new(fail(CommandStatus::NeedsHuman, e))),
Ok(None) => None,
Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
match super::bug::reproduce(&bug_ctx, *b) {
Ok(f) => Some(f),
Err(stop) => return Err(Box::new(*stop)),
}
}
Ok(Some(super::bug::Phase::Reproduce(_))) => None,
};
let bug_candidate = bug_fix.as_ref().and_then(|f| {
f.bug.candidate.clone().and_then(|c| {
crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
.candidate_patch
.map(|d| (d, c))
})
});
let test_layout = crate::layout::detect(project, check_argvs);
let test_layout_text = test_layout.text();
let story_scenarios = match &bug_fix {
Some(f) => super::bug::scenarios(&f.bug),
None => format.scenarios(snap, story_task.as_deref().unwrap_or(task)),
};
let rules = format.rules().judged;
let whole_unit = format.kind() == speccoder_core::spec::FormatKind::Openspec;
let linked_story: Option<(crate::format::OracleRequirement, Vec<String>)> = if bug_fix.is_some()
|| acceptance_story.is_some()
|| whole_unit
|| parsed_task.as_ref().is_none_or(|t| t.story.is_some())
{
None
} else {
parsed_task.as_ref().and_then(|t| {
super::acceptance::linked_story(
t,
&story_scenarios,
unit_dir,
&rules,
&format.oracle_requirements(snap),
)
})
};
let story_scenarios = linked_story
.as_ref()
.map_or(story_scenarios, |(r, _)| r.scenarios.clone());
let story_key: Option<String> = if bug_fix.is_some() || story_scenarios.is_empty() {
None
} else {
acceptance_story
.clone()
.or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone()))
.or_else(|| whole_unit.then(|| feature_name.clone()))
.or_else(|| linked_story.as_ref().map(|(r, _)| r.key.clone()))
};
let parsed_tasks = snap.feature.as_ref().and_then(|f| f.tasks.as_ref());
let story_members: Vec<&speccoder_core::snapshot::Task> = match (&story_key, parsed_tasks) {
(Some(key), Some(pt)) => pt
.tasks
.iter()
.filter(|t| whole_unit || t.story.as_deref() == Some(key.as_str()))
.collect(),
_ => Vec::new(),
};
let judged_by: Option<speccoder_core::story::Judged> = if bug_fix.is_some() {
None
} else if let Some((_, links)) = &linked_story {
Some(speccoder_core::story::Judged::Linked {
scenarios: links.clone(),
})
} else if story_key.is_some() {
let ids: Vec<String> = story_scenarios.iter().map(|s| s.id.clone()).collect();
let st = super::story::story_tasks(&story_members, unit_dir, &rules);
speccoder_core::story::judged(&st, task, &ids).or_else(|| {
acceptance_story
.is_some()
.then_some(speccoder_core::story::Judged::Progress)
})
} else {
parsed_task
.as_ref()
.and_then(|t| super::acceptance::outside_story(t, &story_scenarios, unit_dir, &rules))
};
let in_story =
story_key.is_some() && judged_by.is_some() && run_config.acceptance && planning.is_none();
let scenarios: Vec<speccoder_core::spec::ScenarioRef> = match &judged_by {
Some(speccoder_core::story::Judged::Linked { scenarios: l }) if story_key.is_none() => {
story_scenarios
.iter()
.filter(|s| l.contains(&s.id))
.cloned()
.collect()
}
_ => story_scenarios.clone(),
};
let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
let mut review_reuse = None;
let mut story_oracle: Option<super::story::StoryOracle> = None;
let mut oracle_note = serde_json::Value::Null;
let mut oracle_empty = false;
let mut before_run = super::story::Run::default();
let mut oracle_spent: Option<serde_json::Value> = None;
if in_story && let Some(key) = story_key.as_deref() {
stage(&*live, task, Stage::Red, StageState::Started, None, None);
match super::story::ensure(&super::story::Ensure {
key,
scenarios: &story_scenarios,
feature: feature_name,
project,
wt_root,
state,
plane_dir: &s.plane_dir,
runner,
rt,
plane,
options,
format,
snapshot: snap,
fresh: options.fresh_acceptance,
resuming: interrupted.is_some(),
}) {
Ok((oracle, wrote, said)) => {
notices.extend(said);
oracle_spent.clone_from(&oracle.usage);
if wrote {
notices.push(format!(
"{key}'s oracle: {} test(s) for {} scenario(s), committed {}",
oracle.tests.len(),
oracle.scenarios.len(),
oracle.commit.get(..7).unwrap_or(&oracle.commit)
));
*head_now = git::Git::at(wt_root)
.line(&["rev-parse", "HEAD"])
.unwrap_or_default();
match verify::run_checks(project, Some(verify::config::Tier::Fast), runner) {
Ok(b) => *baseline = b,
Err(e) => {
return Err(Box::new(fail(
CommandStatus::ConfigError,
format!("baseline: {e}"),
)));
}
}
}
before_run = interrupted
.as_ref()
.and_then(|m| m.story_before.clone())
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_else(|| super::story::run(project, &oracle, runner));
if !oracle.tests.is_empty() {
oracle_note = serde_json::json!({
"red": oracle
.scenarios
.iter()
.filter(|s| {
before_run.states.get(&s.id)
!= Some(&speccoder_core::story::ScenarioState::Green)
&& !oracle.untestable.contains_key(&s.id)
})
.map(|s| serde_json::json!({
"id": s.id,
"contract": oracle.contracts.get(&s.id),
"tests": oracle
.tests
.iter()
.filter(|t| t.scenarios.contains(&s.id))
.map(|t| t.path.clone())
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"links": judged_by.as_ref().map(speccoder_core::story::Judged::links).unwrap_or_default(),
"checkpoint": matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
});
}
if let Some((record, manifest)) = oracle.reviewed.clone() {
review_frozen = Some((record, manifest));
}
if let Some((_, links)) = &linked_story
&& !oracle.tests.is_empty()
&& links.iter().all(|l| {
before_run.states.get(l)
== Some(&speccoder_core::story::ScenarioState::Green)
})
{
stage(
&*live,
task,
Stage::Red,
StageState::Skipped,
Some("already holds".to_owned()),
None,
);
let reason = format!(
"{} already hold{} — {key}'s oracle passes {}: nothing for {task} to do",
links.join(", "),
if links.len() == 1 { "s" } else { "" },
if links.len() == 1 {
"its test"
} else {
"their tests"
},
);
let report = speccoder_core::RunReport {
reason: Some(reason.clone()),
notices: notices.clone(),
usage: oracle.usage.clone(),
..bare_report(feature_name, task, wt_path, "holds")
};
return Err(Box::new(
CommandResult::new("run", CommandStatus::NeedsHuman, &reason)
.with_code("TASK-HOLDS")
.with_data(CommandData::Run(Box::new(report))),
));
}
if matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
&& !oracle.untestable.is_empty()
{
let accepted = super::story::accepted_on_branch(wt_root, "HEAD");
let open: Vec<(&String, &String)> = oracle
.untestable
.iter()
.filter(|(id, _)| !accepted.contains(id))
.collect();
if let Some((first, _)) = open.first() {
stage(
&*live,
task,
Stage::Red,
StageState::Skipped,
Some("untestable".to_owned()),
None,
);
let said: Vec<String> = open
.iter()
.map(|(id, why)| format!("{id} (\"{why}\")"))
.collect();
let reason = format!(
"{key} closes with {task}, but its test author called {} untestable — a person accepts each, or has its test written again",
said.join(", ")
);
let report = speccoder_core::RunReport {
reason: Some(reason.clone()),
notices: notices.clone(),
usage: oracle.usage.clone(),
..bare_report(feature_name, task, wt_path, "untestable")
};
return Err(Box::new(CommandResult::new("run", CommandStatus::NeedsHuman, &reason)
.with_code("STORY-UNTESTABLE")
.with_fix(vec![
format!("speccoder story accept {first} --reason \"…\""),
format!("speccoder task rule {task} --tests-wrong --scenario {first} --reason \"…\""),
])
.with_data(CommandData::Run(Box::new(report)))));
}
}
if oracle.tests.is_empty() {
oracle_empty = true;
} else {
story_oracle = Some(oracle);
}
}
Err(r) => {
stage(
&*live,
task,
Stage::Red,
StageState::Failed,
Some(r.message().to_owned()),
None,
);
let reason = format!(
"the acceptance tests could not be written ({}); nothing was attempted",
r.message()
);
let report = speccoder_core::RunReport {
reason: Some(redact(&reason, secret)),
notices: notices.clone(),
..bare_report(feature_name, task, wt_path, "acceptance_failed")
};
let code = r.code().map_or("ORACLE-UNWRITTEN", |c| c.name);
return Err(Box::new(
CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
.with_code(code)
.with_fix(if r.given_fix().is_empty() {
Vec::new()
} else {
r.given_fix()
.iter()
.cloned()
.chain([format!("speccoder run --task {task}")])
.collect()
})
.with_data(CommandData::Run(Box::new(report))),
));
}
}
}
let ruled: Vec<crate::acceptance_at::Ruling> = if bug_fix.is_none() && !in_story {
match crate::acceptance_at::rulings_for_task(&s.plane_dir, feature_name, task) {
Ok(r) => r,
Err(e) => {
return Err(Box::new(fail(
CommandStatus::Error,
format!("the person's rulings cannot be read: {e}"),
)));
}
}
} else {
Vec::new()
};
let told: Vec<String> = ruled
.iter()
.map(|r| format!("{} ruled the tests wrong: {}", r.by, r.reason))
.collect();
let related: Vec<String> = match (parsed_task.as_ref(), parsed_tasks) {
(Some(t), Some(pt)) => super::acceptance::related(t, &pt.tasks),
_ => Vec::new(),
};
if bug_fix.is_none()
&& run_config.acceptance
&& !options.fresh_acceptance
&& planning.is_none()
&& !in_story
&& ruled.is_empty()
{
match crate::oracle::for_task(&s.plane_dir, project, wt_root, format, snap, &scenarios) {
Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
"{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
)),
Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
notices.push(format!("{req}: frozen oracle not present here — authoring"))
}
Some(crate::oracle::TaskReuse::Lapsed {
req,
frozen,
current,
}) => {
if crate::oracle::note_lapse(&s.plane_dir, feature_name, &req, &frozen) {
live.event(&progress::Event::Oracle {
step: progress::OracleEvent::Lapsed {
unit: feature_name.clone(),
requirement: req.clone(),
frozen_wording_sha256: frozen,
current_wording_sha256: current,
},
});
}
notices.push(format!(
"{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
));
}
Some(crate::oracle::TaskReuse::Tampered(why)) => {
return Err(Box::new(fail(
CommandStatus::NeedsHuman,
format!("{why}: the task stops for a person, nothing is ticked"),
)
.with_code("ORACLE-TAMPERED")));
}
Some(crate::oracle::TaskReuse::Unreadable(why)) => {
return Err(Box::new(fail(
CommandStatus::NeedsHuman,
format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
)
.with_code("REVIEW-UNREADABLE")));
}
None => {}
}
}
let mut acceptance = if let Some(prepared) = interrupted
.as_ref()
.and_then(|m| m.acceptance.clone())
.and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
{
let mut prepared = prepared;
if let Some(sealed) = bug_fix
.as_ref()
.and_then(|f| f.record.held_out_sealed.as_ref())
{
prepared.held_out = Some(sealed.manifest_sha256.clone());
prepared.report["held_out"] = serde_json::json!({
"state": "on",
"manifest": format!("sha256:{}", sealed.manifest_sha256),
"kept": sealed.count,
});
}
Some(prepared)
} else if let Some(f) = &bug_fix {
stage(&*live, task, Stage::Red, StageState::Started, None, None);
match super::bug::reuse(&bug_ctx, f) {
Ok(a) => Some(a),
Err(e) => {
let _ = worktree::restore(wt_root, tmp, start);
stage(
&*live,
task,
Stage::Red,
StageState::Failed,
Some(e.clone()),
Some(began),
);
return Err(Box::new(fail(CommandStatus::NeedsHuman, e)));
}
}
} else if let Some(reuse) = review_reuse.take() {
stage(&*live, task, Stage::Red, StageState::Started, None, None);
let crate::oracle::TaskReuse::Ready {
record,
files,
tests,
manifest,
} = reuse
else {
return Err(Box::new(fail(
CommandStatus::Error,
"no frozen oracle".to_owned(),
)));
};
match reuse_pre_frozen(
project,
&record,
&files,
&tests,
runner,
RedRule::RunTime,
Some(&SealedCheck {
state: &s.plane_dir,
unit: feature_name,
wt_root,
tmp,
policy,
}),
) {
Ok(mut a) => {
a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
notices.push(format!(
"acceptance tests frozen at review by {} ({}) for {}: no author call",
record.decided.by, record.decided.via, record.requirement
));
review_frozen = Some((*record, manifest));
Some(a)
}
Err(e) => {
let _ = worktree::restore(wt_root, tmp, start);
stage(
&*live,
task,
Stage::Red,
StageState::Failed,
Some(e.clone()),
Some(began),
);
return Err(Box::new(
fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED"),
));
}
}
} else if run_config.acceptance
&& planning.is_none()
&& !in_story
&& format.acceptance_testable(bundle)
{
stage(&*live, task, Stage::Red, StageState::Started, None, None);
Some(prepare_acceptance(&AcceptanceSetup {
rt,
plane,
project,
worktree: wt_root,
tmp,
policy,
feature: feature_name,
task,
bundle: bundle_value.clone(),
scenarios: &scenarios,
instructions,
runner,
layout: Some(&test_layout),
told: &told,
related: &related,
oracle: false,
}))
} else {
None
};
if review_frozen.is_none()
&& acceptance
.as_ref()
.is_some_and(|a| a.report["source"] == "review")
&& let Some(crate::oracle::TaskReuse::Ready {
record, manifest, ..
}) = review_reuse.take()
{
review_frozen = Some((*record, manifest));
}
let held_out_store = match (&bug_fix, &review_frozen) {
(Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
(None, Some((r, _))) => {
crate::held_out::sealed_dir(&s.plane_dir, feature_name, &r.requirement)
}
(None, None) => crate::held_out::dir(&s.plane_dir, feature_name, task),
};
if let Some(a) = acceptance
.as_mut()
.filter(|a| a.report.get("held_out").is_none())
{
if !run_config.held_out {
a.report["held_out"] = serde_json::json!({ "state": "off" });
} else if a.argv.is_none() {
a.report["held_out"] =
serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
} else if !a.red {
a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
} else if scenarios.is_empty() {
a.report["held_out"] =
serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
} else {
let (held, sha) = prepare_held_out(
&AcceptanceSetup {
rt,
plane,
project,
worktree: wt_root,
tmp,
policy,
feature: feature_name,
task,
bundle: bundle_value.clone(),
scenarios: &scenarios,
instructions,
runner,
layout: Some(&test_layout),
told: &told,
related: &related,
oracle: false,
},
a,
&scenarios,
&held_out_store,
false,
);
a.report["held_out"] = held;
a.held_out = sha;
}
}
if run_config.held_out && !run_config.acceptance {
notices.push(
"[acceptance] held_out has no effect while generate = false: no held-out tests"
.to_owned(),
);
}
match &acceptance {
None => stage(
&*live,
task,
Stage::Red,
StageState::Skipped,
Some(if planning.is_some() {
"planning work: design documents".to_owned()
} else if in_story {
"judged by its story's oracle".to_owned()
} else {
"no acceptance examples".to_owned()
}),
None,
),
Some(a) if a.red => stage(
&*live,
task,
Stage::Red,
StageState::Passed,
a.red_class.map(class_word),
Some(began),
),
Some(a) => stage(
&*live,
task,
Stage::Red,
StageState::Failed,
Some(a.red_class.map_or_else(
|| "not demonstrated".to_owned(),
|c| format!("not demonstrated ({})", class_word(c)),
)),
Some(began),
),
}
if let Some(why) = acceptance
.as_ref()
.and_then(|a| a.generation_failed.clone())
{
let reason = format!(
"the acceptance tests could not be written ({why}); nothing was attempted — run again once the model answers, or set [acceptance] generate = false to rely on the checks alone"
);
let _ = worktree::restore(wt_root, tmp, start);
let report = RunReport {
reason: Some(reason.clone()),
acceptance: acceptance.as_ref().map(|a| a.report.clone()),
set_aside: set_aside.take(),
notices: notices.clone(),
..bare_report(feature_name, task, wt_path, "acceptance_failed")
};
live.event(&finished_event(
task,
&report,
"acceptance_failed",
false,
redact(&reason, secret),
));
return Err(Box::new(
CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
.with_code("ACCEPTANCE-UNWRITTEN")
.with_data(CommandData::Run(Box::new(report))),
));
}
if *demand_red && story_oracle.is_none() && !acceptance.as_ref().is_some_and(|a| a.red) {
let why = undemonstrated_why(acceptance.as_ref());
let reason = format!(
"convergence gap not demonstrated: {why}; a person decides whether {task} is real"
);
let saved = attempts::save(
wt_root,
tmp,
feature_name,
task,
&attempts::Record {
run: None,
outcome: "not_demonstrated",
reason: &reason,
pending: None,
usage: None,
story_open: &[],
own_test: None,
},
)
.ok()
.map(|s| s.reference);
let _ = worktree::restore(wt_root, tmp, start);
let report = RunReport {
reason: Some(reason.clone()),
acceptance: acceptance.as_ref().map(|a| a.report.clone()),
saved_attempt: saved,
set_aside: set_aside.take(),
notices: notices.clone(),
..bare_report(feature_name, task, wt_path, "not_demonstrated")
};
live.event(&finished_event(
task,
&report,
"not_demonstrated",
false,
redact(&reason, secret),
));
return Err(Box::new(
CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
.with_data(CommandData::Run(Box::new(report))),
));
}
Ok(TestsFirst {
bug_fix,
bug_candidate,
test_layout,
test_layout_text,
story_scenarios,
story_key,
judged_by,
in_story,
scenarios,
review_frozen,
story_oracle,
oracle_note,
oracle_empty,
before_run,
oracle_spent,
ruled,
related,
told,
acceptance,
held_out_store,
})
}