use std::path::Path;
use speccoder_core::verify::{RedClass, classify_red};
use crate::{sandbox, substrate, tools, verify, worktree};
pub(crate) struct AcceptanceSetup<'a> {
pub(crate) rt: &'a tokio::runtime::Runtime,
pub(crate) plane: &'a substrate::Plane,
pub(crate) project: &'a Path,
pub(crate) worktree: &'a Path,
pub(crate) tmp: &'a Path,
pub(crate) policy: &'a tools::policy::Policy,
pub(crate) feature: &'a str,
pub(crate) task: &'a str,
pub(crate) bundle: serde_json::Value,
pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
pub(crate) instructions: &'a [crate::instructions::Instruction],
pub(crate) runner: &'a sandbox::Runner,
pub(crate) layout: Option<&'a crate::layout::TestLayout>,
pub(crate) story_files: &'a [String],
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct PreparedAcceptance {
pub(crate) report: serde_json::Value,
pub(crate) argv: Option<Vec<String>>,
#[serde(default)]
pub(crate) more_argv: Vec<Vec<String>>,
pub(crate) red: bool,
#[serde(default)]
pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
#[serde(default)]
pub(crate) held_out: Option<String>,
#[serde(default)]
pub(crate) generation_failed: Option<String>,
#[serde(default)]
pub(crate) skip_cause: Option<speccoder_core::regression::SkipCause>,
}
pub(crate) const MANIFESTS: &[&str] = &[
"Cargo.toml",
"package.json",
"pyproject.toml",
"go.mod",
"pom.xml",
];
pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
use serde_json::json;
use speccoder_core::regression::SkipCause;
let skipped_by = |why: String, cause: Option<SkipCause>, report: serde_json::Value| {
let mut report = report;
report["skipped"] = json!(why);
PreparedAcceptance {
report,
argv: None,
more_argv: Vec::new(),
red: false,
red_class: None,
held_out: None,
generation_failed: None,
skip_cause: cause,
}
};
let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
generation_failed: Some(why.clone()),
..skipped_by(why, None, report)
};
let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
.and_then(|c| worktree::files(a.worktree, &c))
.unwrap_or_default()
.into_iter()
.take(400)
.collect();
let manifest = MANIFESTS
.iter()
.find_map(|m| {
std::fs::read_to_string(a.project.join(m))
.ok()
.map(|t| format!("{m}:\n{t}"))
})
.map(|t| t.chars().take(8192).collect::<String>())
.unwrap_or_default();
let placement = match placement(a) {
Ok(p) => p,
Err(()) => {
return skipped_by(
"no place the runner collects".to_owned(),
Some(SkipCause::NoPlace),
json!({}),
);
}
};
let input = substrate::acceptance::input(
a.bundle.clone(),
a.scenarios,
&files,
&manifest,
a.instructions,
placement.as_ref(),
);
let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
let outcome = match outcome {
Ok(o) => o,
Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
};
let output = outcome
.output
.as_ref()
.map(|o| o.peek().clone())
.unwrap_or_default();
let answer = output["answer"].clone();
let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
let status = format!("{:?}", outcome.status);
let why = substrate::provider_refusal(&status).map_or_else(
|| format!("generation failed: {}", substrate::why_not(&outcome.status)),
|r| format!("the model provider refused the request: {r}"),
);
return failed(why, base);
}
let mut written = Vec::new();
let story = placement
.as_ref()
.and_then(|p| p.existing.as_deref().map(|old| (p.path.as_str(), old)));
let (valid, refused) = validate_with(&answer, a.project, a.policy, a.scenarios, story);
if let Some(r) = refused.first() {
return skipped_by(
r.message.clone(),
Some(SkipCause::Refused {
path: r.path.clone(),
why: refusal_words(r.why).to_owned(),
}),
base,
);
}
let tests: Vec<serde_json::Value> = valid
.iter()
.map(|f| json!({ "path": f.path, "scenarios": f.cited }))
.collect();
let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
if planned.is_empty() {
return skipped_by(
"no acceptance tests were generated".to_owned(),
Some(SkipCause::NoFiles),
base,
);
}
let argv: Vec<String> = argv_of(&answer["argv"]);
let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
return skipped_by(
format!("`{}` is not an allowed command", argv.join(" ")),
Some(SkipCause::CommandNotAllowed {
argv: argv.join(" "),
}),
base,
);
}
for (rel, content) in &planned {
let full = a.project.join(rel);
if let Some(parent) = full.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&full, content) {
return skipped_by(
format!("`{rel}`: {e}"),
Some(SkipCause::WriteFailed { path: rel.clone() }),
base,
);
}
written.push(rel.clone());
}
let (first, summary) = execute(a.project, &argv, a.runner);
let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
let mut class = classify_red(&first, summary.as_ref());
if let Some((path, old)) = story.filter(|(p, _)| written.iter().any(|w| w == p)) {
let full = a.project.join(path);
if let Ok(new) = std::fs::read_to_string(&full)
&& std::fs::write(&full, old).is_ok()
{
let (without, wsum) = execute(a.project, &argv, a.runner);
class = if std::fs::write(&full, &new).is_err()
|| std::fs::read_to_string(&full).ok().as_deref() != Some(new.as_str())
{
RedClass::Unclassified
} else {
speccoder_core::verify::classify_red_added(
(&first, summary.as_ref()),
(&without, wsum.as_ref()),
)
};
}
}
if class.is_red() {
let (second, summary) = execute(a.project, &argv, a.runner);
if !classify_red(&second, summary.as_ref()).is_red() {
class = RedClass::Flaky;
}
}
let red = class.is_red();
let mut report = base;
report["files"] = json!(written);
if let Some(p) = placement.as_ref().filter(|p| p.story.is_some()) {
report["story_file"] = json!(p.path);
report["appended"] = json!(p.existing.is_some());
}
report["tests"] = json!(tests);
report["argv"] = json!(argv);
report["red"] = json!(red);
report["red_class"] = json!(class);
if let Some(n) = red_tests {
report["red_tests"] = json!(n);
}
if red {
report["red_output"] = json!(
format!("{}\n{}", first.stdout_tail, first.stderr_tail)
.trim()
.to_owned()
);
} else {
report["note"] = json!(match class {
RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
other => format!(
"the acceptance tests did not fail on an assertion ({}): not demonstrated",
class_word(other)
),
});
report["excerpt"] = json!(excerpt(&first));
}
PreparedAcceptance {
report,
argv: Some(argv),
more_argv: Vec::new(),
red,
red_class: Some(class),
held_out: None,
generation_failed: None,
skip_cause: None,
}
}
fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
return Ok(None);
};
let task = a.task.to_owned();
let story = a.bundle["task"]["story"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned);
let package =
speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
.into_iter()
.find(|p| p.ends_with(".go") || !p.contains('.'))
.map(|p| {
if p.ends_with(".go") {
p.rsplit_once('/')
.map_or(String::new(), |(d, _)| d.to_owned())
} else {
p.trim_end_matches('/').to_owned()
}
});
let per_task =
|layout: &crate::layout::TestLayout| layout.story_file(&task, package.as_deref());
let (path, story, existing) = match story.as_ref() {
Some(st) => {
let Some(path) = layout.story_file(st, package.as_deref()) else {
return Err(());
};
let exists = a.project.join(&path).exists();
if !exists {
(path, story.clone(), None)
} else if a.story_files.contains(&path) {
let old = std::fs::read_to_string(a.project.join(&path)).unwrap_or_default();
(path, story.clone(), Some(old))
} else {
(per_task(layout).ok_or(())?, None, None)
}
}
None => (per_task(layout).ok_or(())?, None, None),
};
let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
Ok(Some(substrate::acceptance::Placement {
command: layout.command_for(&path).unwrap_or_default(),
path,
existing,
task,
story,
scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
comment,
layout: layout.text(),
}))
}
pub(crate) fn refusal_words(why: &str) -> &'static str {
match why {
"exists" => "already exists",
"not_collected" => "is not where the runner collects tests",
"changes_existing" => "changes existing lines",
"skip_marker" => "adds a section that skips a test",
_ => "is not a new test file",
}
}
pub(crate) struct ValidFile {
pub(crate) path: String,
pub(crate) content: String,
pub(crate) cited: Vec<String>,
pub(crate) argv: Vec<String>,
}
pub(crate) struct RefusedFile {
pub(crate) path: String,
pub(crate) why: &'static str,
pub(crate) message: String,
}
#[must_use]
pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
let argv: Vec<String> = v
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
match argv.as_slice() {
[line] if line.split_ascii_whitespace().count() > 1 => {
let words = crate::acp::govern::argv_of_line(line);
if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
argv
} else {
words
}
}
_ => argv,
}
}
pub(crate) fn test_command_allowed(
policy: &tools::policy::Policy,
layout: Option<&crate::layout::TestLayout>,
argv: &[String],
files: &[&str],
) -> bool {
layout.is_some_and(|l| l.runs_only(argv, files))
|| policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
== tools::policy::Decision::Allow
}
pub(crate) fn validate_answer(
answer: &serde_json::Value,
project: &Path,
policy: &tools::policy::Policy,
scenarios: &[speccoder_core::spec::ScenarioRef],
) -> (Vec<ValidFile>, Vec<RefusedFile>) {
validate_with(answer, project, policy, scenarios, None)
}
pub(crate) fn validate_with(
answer: &serde_json::Value,
project: &Path,
policy: &tools::policy::Policy,
scenarios: &[speccoder_core::spec::ScenarioRef],
story: Option<(&str, &str)>,
) -> (Vec<ValidFile>, Vec<RefusedFile>) {
let protected = policy.protected();
let mut valid = Vec::new();
let mut refused = Vec::new();
for f in answer["files"].as_array().into_iter().flatten() {
let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
continue;
};
let rel = match tools::paths::normalise(path) {
Ok(r) => r,
Err(e) => {
refused.push(RefusedFile {
path: path.to_owned(),
why: "not_a_new_test_file",
message: format!("`{path}`: {}", e.0),
});
continue;
}
};
if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
refused.push(RefusedFile {
message: format!("`{rel}` is not a new test file"),
path: rel,
why: "not_a_new_test_file",
});
continue;
}
match story {
Some((path, old)) if path == rel => {
match crate::integrity::appended_section(old, content) {
None => {
refused.push(RefusedFile {
message: format!("changes existing lines of `{rel}`"),
path: rel,
why: "changes_existing",
});
continue;
}
Some(added) if crate::integrity::has_skip_marker(added) => {
refused.push(RefusedFile {
message: format!("`{rel}`: the added section skips a test"),
path: rel,
why: "skip_marker",
});
continue;
}
Some(_) => {}
}
}
_ if project.join(&rel).exists() => {
refused.push(RefusedFile {
message: format!("`{rel}` already exists"),
path: rel,
why: "exists",
});
continue;
}
_ => {
if crate::collection::statically_refused(project, &rel, content).is_some() {
refused.push(RefusedFile {
message: format!("`{rel}` is not where the runner collects tests"),
path: rel,
why: "not_collected",
});
continue;
}
}
}
let cited: Vec<String> = f["scenarios"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.filter(|id| scenarios.iter().any(|s| s.id == *id))
.map(str::to_owned)
.collect();
let argv: Vec<String> = argv_of(&f["argv"]);
valid.push(ValidFile {
path: rel,
content: content.to_owned(),
cited,
argv,
});
}
(valid, refused)
}
pub(crate) fn class_word(class: RedClass) -> String {
serde_json::to_value(class)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
}
fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
let from = lines
.iter()
.position(|l| {
let l = l.to_lowercase();
l.contains("error") || l.contains("fail")
})
.unwrap_or_else(|| lines.len().saturating_sub(20));
lines
.iter()
.skip(from)
.take(20)
.copied()
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn acceptance_run(
project: &Path,
argv: &[String],
runner: &sandbox::Runner,
) -> (
speccoder_core::verify::CheckResult,
Option<speccoder_core::verify::RunnerSummary>,
) {
execute(project, argv, runner)
}
pub(crate) fn run_acceptance(
project: &Path,
argv: &[String],
runner: &sandbox::Runner,
) -> speccoder_core::verify::CheckResult {
execute(project, argv, runner).0
}
fn execute(
project: &Path,
argv: &[String],
runner: &sandbox::Runner,
) -> (
speccoder_core::verify::CheckResult,
Option<speccoder_core::verify::RunnerSummary>,
) {
use speccoder_core::verify::{CheckResult, CheckStatus};
use verify::process::CommandRunner;
let out = runner.run(&verify::process::Invocation {
argv: argv.to_vec(),
cwd: project.to_path_buf(),
env: verify::process::scrubbed_env(std::env::vars()),
timeout: std::time::Duration::from_secs(600),
output_cap: verify::process::OUTPUT_CAP,
});
let status = if out.spawn_error.is_some() {
CheckStatus::Error
} else if out.timed_out {
CheckStatus::TimedOut
} else if out.exit_code == Some(0) {
CheckStatus::Passed
} else {
CheckStatus::Failed
};
let summary = verify::summary::parse(
&String::from_utf8_lossy(&out.stdout.kept),
&String::from_utf8_lossy(&out.stderr.kept),
out.exit_code,
);
let tail = |b: &[u8]| {
let start = b.len().saturating_sub(4096);
String::from_utf8_lossy(&b[start..]).into_owned()
};
let result = CheckResult {
name: "acceptance".to_owned(),
argv: argv.to_vec(),
status,
exit_code: out.exit_code,
duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
junit: false,
tests: None,
stdout_tail: tail(&out.stdout.kept),
stderr_tail: tail(&out.stderr.kept),
truncated: out.stdout.truncated() || out.stderr.truncated(),
note: out.spawn_error,
flaky: false,
};
(result, summary)
}
pub(crate) fn prepare_held_out(
a: &AcceptanceSetup<'_>,
visible: &PreparedAcceptance,
scenarios: &[speccoder_core::spec::ScenarioRef],
store: &Path,
composition: bool,
) -> (serde_json::Value, Option<String>) {
use serde_json::json;
let absent = |reason: &str, mut report: serde_json::Value| {
report["state"] = json!("absent");
report["reason"] = json!(reason);
(report, None)
};
let visible_files: Vec<(String, String)> = visible.report["files"]
.as_array()
.into_iter()
.flatten()
.filter_map(|p| p.as_str())
.map(|p| {
(
p.to_owned(),
std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
)
})
.collect();
let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
.and_then(|c| worktree::files(a.worktree, &c))
.unwrap_or_default()
.into_iter()
.take(400)
.collect();
let manifest_text = MANIFESTS
.iter()
.find_map(|m| {
std::fs::read_to_string(a.project.join(m))
.ok()
.map(|t| format!("{m}:\n{t}"))
})
.map(|t| t.chars().take(8192).collect::<String>())
.unwrap_or_default();
let input = substrate::acceptance::held_out_input(
a.bundle.clone(),
&files,
&manifest_text,
scenarios,
&visible_files,
composition,
a.instructions,
a.layout.map(crate::layout::TestLayout::text).as_deref(),
);
let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
Ok(o) => o,
Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
};
let output = outcome
.output
.as_ref()
.map(|o| o.peek().clone())
.unwrap_or_default();
let answer = output["answer"].clone();
let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
return absent("generation_failed", base);
}
let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
let protected = a.policy.protected();
let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
let mut discard = |path: &str, reason: &str| {
discarded.push(crate::held_out::Discarded {
path: path.to_owned(),
reason: reason.to_owned(),
});
};
let given = answer["files"].as_array().cloned().unwrap_or_default();
if given.is_empty() {
return absent("none_generated", base);
}
for f in &given {
let path = f["path"].as_str().unwrap_or_default();
let content = f["content"].as_str().unwrap_or_default();
let cited: Vec<String> = f["scenarios"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect();
let argv: Vec<String> = argv_of(&f["argv"]);
let rel = match tools::paths::normalise(path) {
Ok(r) => r,
Err(_) => {
discard(path, "path");
continue;
}
};
let taken =
visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
if !tools::paths::is_test_path(&rel)
|| protected.write_class(&rel).is_some()
|| a.project.join(&rel).exists()
|| taken
{
discard(&rel, "path");
continue;
}
if crate::collection::statically_refused(a.project, &rel, content).is_some() {
discard(&rel, "not_collected");
continue;
}
if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
discard(&rel, "unknown_scenario");
continue;
}
let composed = f["composition"].as_bool().unwrap_or(false);
if composed && (!composition || cited.len() < 2) {
discard(&rel, "composition");
continue;
}
if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
discard(&rel, "command");
continue;
}
if kept.len() >= crate::held_out::LIMIT {
discard(&rel, "limit");
continue;
}
kept.push((
crate::held_out::HeldOutFile {
path: rel,
sha256: crate::integrity::sha256_hex(content.as_bytes()),
scenarios: cited,
argv,
composition: composed,
red_tests: None,
},
content.to_owned(),
));
}
let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
.map(|c| c.0)
.unwrap_or_default();
match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
Ok(runs) => {
let mut red = Vec::new();
for ((mut file, content), ran) in kept.into_iter().zip(runs) {
if ran.result.is_none() {
discard(&file.path, "collision");
} else if crate::held_out::red_class(&ran).is_red() {
file.red_tests = ran
.result
.as_ref()
.and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
red.push((file, content));
} else {
discard(&file.path, "not_red");
}
}
kept = red;
}
Err(e) => {
return absent(
"generation_failed",
json!({ "error": e, "run_id": base["run_id"] }),
);
}
}
let mut report = base;
report["discarded"] = json!(discarded);
if kept.is_empty() {
let only_uncollected =
!discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
return absent(
if only_uncollected {
"not_collected"
} else {
"all_discarded"
},
report,
);
}
let manifest = crate::held_out::Manifest {
version: 1,
feature: a.feature.to_owned(),
task: a.task.to_owned(),
author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
checkpoint,
visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
files: kept.iter().map(|(f, _)| f.clone()).collect(),
discarded,
};
let contents: Vec<(String, String)> = kept
.iter()
.map(|(f, c)| (f.path.clone(), c.clone()))
.collect();
match crate::held_out::store(store, &manifest, &contents) {
Ok(sha) => {
report["state"] = json!("on");
report["manifest"] = json!(format!("sha256:{sha}"));
report["kept"] = json!(kept.len());
(report, Some(sha))
}
Err(e) => absent("generation_failed", json!({ "error": e })),
}
}
pub(crate) struct HeldOutJudged {
pub(crate) held_out: speccoder_core::verify::HeldOut,
pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn judge_held_out(
store: &Path,
manifest_sha: &str,
scenarios: &[speccoder_core::spec::ScenarioRef],
known_text: &str,
wt_root: &Path,
project: &Path,
tmp: &Path,
start: &worktree::Checkpoint,
patch: &str,
runner: &sandbox::Runner,
policy: &tools::policy::Policy,
) -> Result<HeldOutJudged, (&'static str, String)> {
use speccoder_core::verify::{CheckStatus, HeldOut};
let (manifest, contents) =
crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
.files
.iter()
.cloned()
.zip(contents.into_iter().map(|(_, c)| c))
.collect();
let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
if !leaks.is_empty() {
return Err((
"leak_suspected",
format!(
"the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
leaks.len()
),
));
}
let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
.map_err(|e| ("tampered", e))?;
let mut failed_ids: Vec<String> = Vec::new();
for ((file, _), ran) in files.iter().zip(&runs) {
let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
files
.iter()
.find(|(f, _)| f.path == file.path)
.map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
});
let required = file.red_tests.unwrap_or(declared).max(1);
let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
r.status == CheckStatus::Passed
&& speccoder_core::verify::collected_count(r, sum.as_ref())
.is_some_and(|green| green >= required)
});
if !passed {
failed_ids.extend(file.scenarios.iter().cloned());
}
}
failed_ids.sort();
failed_ids.dedup();
let held_out = if failed_ids.is_empty() {
HeldOut::Passed { files: files.len() }
} else {
HeldOut::Failed {
scenarios: failed_ids.clone(),
}
};
let failed = failed_ids
.iter()
.map(|id| {
scenarios
.iter()
.find(|s| &s.id == id)
.cloned()
.unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
id: id.clone(),
text: String::new(),
})
})
.collect();
Ok(HeldOutJudged { held_out, failed })
}
pub(crate) struct Strengthened {
pub(crate) files: Vec<String>,
pub(crate) argv: Vec<String>,
pub(crate) report: serde_json::Value,
}
pub(crate) fn strengthen(
a: &AcceptanceSetup<'_>,
visible: &[(String, String)],
weak: serde_json::Value,
before: &worktree::Checkpoint,
after: &worktree::Checkpoint,
) -> Result<Strengthened, String> {
use serde_json::json;
let files: Vec<String> = worktree::files(a.worktree, after)
.unwrap_or_default()
.into_iter()
.take(400)
.collect();
let manifest = MANIFESTS
.iter()
.find_map(|m| {
std::fs::read_to_string(a.project.join(m))
.ok()
.map(|t| format!("{m}:\n{t}"))
})
.map(|t| t.chars().take(8192).collect::<String>())
.unwrap_or_default();
let input = substrate::acceptance::strengthen_input(
a.bundle.clone(),
a.scenarios,
&files,
&manifest,
visible,
weak,
a.instructions,
);
let outcome =
a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
.map_err(|e| format!("the test author failed: {}", e.0))?;
let output = outcome
.output
.as_ref()
.map(|o| o.peek().clone())
.unwrap_or_default();
let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
let answer = &output["answer"];
if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
return Err(format!(
"the test author did not answer ({})",
substrate::why_not(&outcome.status)
));
}
let protected = a.policy.protected();
let mut planned = Vec::new();
for f in answer["files"].as_array().into_iter().flatten() {
let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
continue;
};
let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
if !tools::paths::is_test_path(&rel)
|| protected.write_class(&rel).is_some()
|| a.project.join(&rel).exists()
{
return Err(format!("`{rel}` is not a new test file"));
}
planned.push((rel, content.to_owned()));
}
if planned.is_empty() {
return Err("the test author added no tests".to_owned());
}
let argv: Vec<String> = argv_of(&answer["argv"]);
let paths: Vec<&str> = planned
.iter()
.map(|(p, _): &(String, String)| p.as_str())
.collect();
if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
return Err(format!("`{}` is not an allowed command", argv.join(" ")));
}
let write = || -> Result<(), String> {
for (rel, content) in &planned {
let full = a.project.join(rel);
if let Some(parent) = full.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
}
Ok(())
};
let back =
|to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
back(before)?;
let red = write().map(|()| {
let (r, summary) = execute(a.project, &argv, a.runner);
classify_red(&r, summary.as_ref())
});
back(after)?;
let red = red?;
if !red.is_red() {
return Err(format!(
"the added tests do not fail without the change ({})",
class_word(red)
));
}
write()?;
let (green, _) = execute(a.project, &argv, a.runner);
if green.status != speccoder_core::verify::CheckStatus::Passed {
back(after)?;
let cited: Vec<String> = answer["files"]
.as_array()
.into_iter()
.flatten()
.flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
return Err(format!(
"the added tests fail the implementation{}",
if cited.is_empty() {
String::new()
} else {
format!(" (scenarios {})", cited.join(", "))
}
));
}
Ok(Strengthened {
files: planned.into_iter().map(|(p, _)| p).collect(),
argv,
report,
})
}
pub(crate) fn story_checkpoint(
tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
task: &str,
scenarios: &[speccoder_core::spec::ScenarioRef],
) -> bool {
let Some(tasks) = tasks else { return false };
let Some(label) = tasks
.tasks
.iter()
.find(|t| t.id == task)
.and_then(|t| t.story.as_deref())
else {
return false;
};
let last = tasks
.tasks
.iter()
.rfind(|t| t.story.as_deref() == Some(label))
.map(|t| t.id.as_str());
last == Some(task) && scenarios.len() >= 2
}
#[cfg(test)]
mod argv_tests {
use super::argv_of;
use serde_json::json;
#[test]
fn a_command_given_as_one_string_is_its_words() {
assert_eq!(
argv_of(&json!(["cargo test --test acceptance_us1"])),
["cargo", "test", "--test", "acceptance_us1"]
);
assert_eq!(
argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
["cargo", "test", "--test", "acceptance_us1"]
);
assert_eq!(
argv_of(&json!(["cargo test; rm -rf ."])),
["cargo test; rm -rf ."]
);
assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_last_task_of_a_story_with_two_scenarios_composes() {
let (tasks, _) = speccoder_speckit::tasks::parse(
"tasks.md",
"## Phase 3: User Story 1 - Login\n\n- [ ] T005 [US1] a\n- [ ] T006 [US1] b\n\n## Phase 4: User Story 2 - X\n\n- [ ] T007 [US2] c\n",
);
let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
.iter()
.map(|id| speccoder_core::spec::ScenarioRef {
id: (*id).to_owned(),
text: String::new(),
})
.collect();
assert!(story_checkpoint(Some(&tasks), "T006", &two));
assert!(!story_checkpoint(Some(&tasks), "T005", &two));
assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
assert!(!story_checkpoint(None, "T006", &two));
}
fn record(
producer: speccoder_core::acceptance::Producer,
content: &str,
) -> speccoder_core::acceptance::PreFrozenAcceptance {
speccoder_core::acceptance::PreFrozenAcceptance {
unit: "001-x".to_owned(),
requirement: "US1".to_owned(),
wording_digest: "sha256:w".to_owned(),
approval_digest: "sha256:a".to_owned(),
visible: vec![speccoder_core::acceptance::VisibleTest {
path: "tests/a.txt".to_owned(),
sha256: format!(
"sha256:{}",
crate::integrity::sha256_hex(content.as_bytes())
),
cites: vec!["US1-1".to_owned()],
}],
held_out_sealed: None,
producer,
decided: speccoder_core::acceptance::Decided {
by: "Ada".to_owned(),
via: speccoder_core::actions::Via::Terminal,
at: 0,
},
}
}
#[test]
fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
use speccoder_core::acceptance::Producer;
let summary = |passed: u8, failed: u8| {
format!(
"echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
if failed > 0 { "FAILED" } else { "ok" },
if failed > 0 { 101 } else { 0 }
)
};
let content = "checks\n";
for (producer, rule) in [
(Producer::Review, RedRule::RunTime),
(Producer::BugConfirmation, RedRule::BugAssertionOnly),
] {
let rec = record(producer, content);
let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
let run = |script: String| {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let tests = [ReusedTest {
path: "tests/a.txt".to_owned(),
cites: vec!["US1-1".to_owned()],
argv: vec!["sh".to_owned(), "-c".to_owned(), script],
green: false,
}];
let got = reuse_pre_frozen(
dir.path(),
&rec,
&files,
&tests,
&sandbox::Runner::Plain,
rule,
None,
);
assert!(
dir.path().join("tests/a.txt").is_file(),
"written at the checkpoint"
);
got
};
let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
assert!(red.red);
assert_eq!(red.report["source"], serde_json::json!(producer.source()));
let passing = run(summary(1, 0));
match rule {
RedRule::RunTime => {
let p = passing.unwrap_or_else(|e| panic!("{e}"));
assert!(!p.red);
assert!(
p.report["note"]
.as_str()
.is_some_and(|n| n.contains("already passing"))
);
}
RedRule::BugAssertionOnly => assert!(passing.is_err()),
}
if rule == RedRule::BugAssertionOnly {
assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RedRule {
RunTime,
BugAssertionOnly,
}
#[derive(Debug)]
pub(crate) struct ReusedTest {
pub(crate) path: String,
pub(crate) cites: Vec<String>,
pub(crate) argv: Vec<String>,
pub(crate) green: bool,
}
pub(crate) struct SealedCheck<'a> {
pub(crate) state: &'a Path,
pub(crate) unit: &'a str,
pub(crate) wt_root: &'a Path,
pub(crate) tmp: &'a Path,
pub(crate) policy: &'a tools::policy::Policy,
}
pub(crate) fn reuse_pre_frozen(
project: &Path,
record: &speccoder_core::acceptance::PreFrozenAcceptance,
files: &[(String, String)],
tests: &[ReusedTest],
runner: &sandbox::Runner,
rule: RedRule,
sealed: Option<&SealedCheck<'_>>,
) -> Result<PreparedAcceptance, String> {
use serde_json::json;
for (rel, content) in files {
let full = project.join(rel);
if let Ok(now) = std::fs::read_to_string(&full) {
if now != *content {
return Err(format!(
"`{rel}` exists and differs from the confirmed test; it is not overwritten"
));
}
continue;
}
if let Some(parent) = full.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
}
let class_of = |r: &speccoder_core::verify::CheckResult,
s: Option<&speccoder_core::verify::RunnerSummary>| match rule
{
RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
RedRule::RunTime => classify_red(r, s),
};
let mut classes = Vec::new();
for t in tests {
let (first, summary) = execute(project, &t.argv, runner);
let one = class_of(&first, summary.as_ref());
let class = if t.green {
one
} else {
match rule {
RedRule::BugAssertionOnly if one == RedClass::Assertion => {
let (second, summary) = execute(project, &t.argv, runner);
speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
}
RedRule::BugAssertionOnly => one,
RedRule::RunTime if one.is_red() => {
let (second, summary) = execute(project, &t.argv, runner);
if classify_red(&second, summary.as_ref()).is_red() {
one
} else {
RedClass::Flaky
}
}
RedRule::RunTime => one,
}
};
let holds = if t.green {
class == RedClass::Passed
} else {
match rule {
RedRule::BugAssertionOnly => class == RedClass::Assertion,
RedRule::RunTime => class.is_red(),
}
};
if !holds && rule == RedRule::RunTime && !t.green {
return Ok(not_red_frozen(record, files, tests, t, class));
}
if !holds {
return Err(if t.green {
format!(
"the confirmed test `{}` ({}) no longer passes on the branch ({})",
t.path,
t.cites.join(", "),
class_word(class)
)
} else {
format!(
"the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
t.path,
t.cites.join(", "),
class_word(class)
)
});
}
classes.push(class);
}
let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
let Some(first) = red.first() else {
return Err("the confirmed set has no test that must fail first".to_owned());
};
let more: Vec<Vec<String>> = tests
.iter()
.filter(|t| !std::ptr::eq(*t, *first))
.map(|t| t.argv.clone())
.collect();
let mut held_sha = record
.held_out_sealed
.as_ref()
.map(|h| h.manifest_sha256.clone());
let mut held_out = match &record.held_out_sealed {
Some(h) => {
json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
}
None => json!({ "state": "absent", "reason": "none_kept" }),
};
if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
.map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
.files
.iter()
.cloned()
.zip(contents.into_iter().map(|(_, c)| c))
.collect();
let runs = crate::held_out::overlay_red(
check.wt_root,
project,
check.tmp,
&set,
runner,
check.policy,
)?;
let all_red = runs
.iter()
.all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
if !all_red {
held_sha = None;
held_out = json!({ "state": "absent", "reason": "not_red" });
}
}
let class = classes.first().copied().unwrap_or(RedClass::Assertion);
Ok(PreparedAcceptance {
report: json!({
"files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
"tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
"argv": first.argv,
"red": true,
"red_class": class,
"source": record.producer.source(),
"pre_frozen": {
"manifest": crate::pre_frozen::manifest_digest(record),
"approval": record.approval_digest,
"wording": record.wording_digest,
"by": record.decided.by,
"via": record.decided.via,
},
"held_out": held_out,
}),
argv: Some(first.argv.clone()),
more_argv: more,
red: true,
red_class: Some(class),
held_out: held_sha,
generation_failed: None,
skip_cause: None,
})
}
fn not_red_frozen(
record: &speccoder_core::acceptance::PreFrozenAcceptance,
files: &[(String, String)],
tests: &[ReusedTest],
failing: &ReusedTest,
class: RedClass,
) -> PreparedAcceptance {
use serde_json::json;
let note = match class {
RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
other => format!(
"frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
class_word(other)
),
};
PreparedAcceptance {
report: json!({
"files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
"tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
"argv": failing.argv,
"red": false,
"red_class": class,
"note": note,
"source": record.producer.source(),
"pre_frozen": {
"manifest": crate::pre_frozen::manifest_digest(record),
"approval": record.approval_digest,
"wording": record.wording_digest,
"by": record.decided.by,
"via": record.decided.via,
},
"held_out": { "state": "absent", "reason": "not_red" },
}),
argv: Some(failing.argv.clone()),
more_argv: tests
.iter()
.filter(|t| !std::ptr::eq(*t, failing))
.map(|t| t.argv.clone())
.collect(),
red: false,
red_class: Some(class),
held_out: None,
generation_failed: None,
skip_cause: None,
}
}