1use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use std::collections::HashMap;
55use std::future::Future;
56use std::time::Duration;
57
58const CONTRACT_GEN_TIMEOUT: Duration = Duration::from_secs(120);
64
65use super::budget::SessionDeadline;
66use super::session::{CoderEventKind, EventSink};
67use super::shell_tool::WorktreeExecutor;
68
69fn default_true() -> bool {
70 true
71}
72
73fn default_check_timeout() -> u64 {
74 120
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct OutcomeContract {
80 pub description: String,
82 pub checks: Vec<ContractCheck>,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct ContractCheck {
98 pub name: String,
100 pub command: String,
102 #[serde(default = "default_true")]
104 pub expect_exit_zero: bool,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub output_contains: Option<String>,
110 #[serde(default = "default_check_timeout")]
112 pub timeout_secs: u64,
113 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123 pub baseline: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub differential: Option<DifferentialCheck>,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct DifferentialCheck {
136 pub baseline: String,
140 pub expect: DifferentialExpect,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum DifferentialExpect {
150 Changed,
153 Unchanged,
156 DeltaWithin {
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 min: Option<f64>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 max: Option<f64>,
165 },
166}
167
168pub type BaselineCaptures = HashMap<String, CheckResult>;
175
176pub fn collect_baseline_captures(
179 contract: &OutcomeContract,
180 baseline_results: &[CheckResult],
181) -> BaselineCaptures {
182 contract
183 .checks
184 .iter()
185 .filter(|c| c.baseline)
186 .filter_map(|c| {
187 baseline_results
188 .iter()
189 .find(|r| r.name == c.name)
190 .map(|r| (c.name.clone(), r.clone()))
191 })
192 .collect()
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct CheckResult {
198 pub name: String,
199 pub passed: bool,
200 pub exit_code: Option<i64>,
202 pub output_tail: String,
204 pub duration_ms: u64,
205 #[serde(default)]
210 pub timed_out: bool,
211 #[serde(default)]
217 pub deadline_clamped: bool,
218}
219
220impl CheckResult {
221 pub fn starved_by_deadline(&self) -> bool {
232 self.timed_out && self.deadline_clamped
233 }
234}
235
236impl OutcomeContract {
237 pub fn validate(&self) -> Vec<String> {
248 let mut issues = Vec::new();
249 if self.checks.is_empty() {
250 issues.push("contract has no checks — at least one is required".to_string());
251 }
252 let mut seen = std::collections::HashSet::new();
253 for (i, c) in self.checks.iter().enumerate() {
254 let name = c.name.trim();
255 if name.is_empty() {
256 issues.push(format!("check #{i} has an empty name"));
257 }
258 if name == "unique_snake_case_label" {
259 issues.push(format!(
260 "check #{i} kept the literal placeholder name \
261 'unique_snake_case_label' — give it a real descriptive label"
262 ));
263 }
264 if c.command.trim().is_empty() {
265 issues.push(format!("check '{}' has an empty command", c.name));
266 } else if is_toolchain_only(c.command.trim()) {
267 issues.push(format!(
268 "check '{}' runs a toolchain-only no-op (`{}`) that verifies the \
269 tool is installed, not the task — replace it with a command that \
270 exercises the actual change",
271 c.name,
272 c.command.trim()
273 ));
274 }
275 if !seen.insert(name.to_string()) {
276 issues.push(format!("duplicate check name '{}'", c.name));
277 }
278 if !c.expect_exit_zero && c.output_contains.is_none() && !c.baseline {
282 issues.push(format!(
283 "check '{}' asserts nothing (expect_exit_zero=false and no output_contains)",
284 c.name
285 ));
286 }
287 if c.baseline && c.differential.is_some() {
288 issues.push(format!(
289 "check '{}' is both a baseline capture and a differential — a capture is \
290 the before-value, it cannot also diff against one; split it into two \
291 checks",
292 c.name
293 ));
294 }
295 if let Some(diff) = &c.differential {
296 let target = self
300 .checks
301 .iter()
302 .position(|t| t.name == diff.baseline && t.baseline);
303 match target {
304 None => issues.push(format!(
305 "check '{}' diffs against baseline '{}', but no check by that name is \
306 marked baseline: true",
307 c.name, diff.baseline
308 )),
309 Some(pos) if pos >= i => issues.push(format!(
310 "check '{}' diffs against baseline '{}', which is declared after it — \
311 declare the capture first",
312 c.name, diff.baseline
313 )),
314 Some(_) => {}
315 }
316 match &diff.expect {
319 DifferentialExpect::Changed | DifferentialExpect::Unchanged => {}
320 DifferentialExpect::DeltaWithin { min, max } => {
321 if min.is_none() && max.is_none() {
322 issues.push(format!(
323 "check '{}' declares delta_within with no bounds — an unbounded \
324 delta asserts nothing; state min, max, or both",
325 c.name
326 ));
327 }
328 if let (Some(lo), Some(hi)) = (min, max) {
329 if lo > hi {
330 issues.push(format!(
331 "check '{}' declares delta_within bounds [{lo}, {hi}] with \
332 min above max — no delta can satisfy that",
333 c.name
334 ));
335 }
336 }
337 }
338 }
339 }
340 }
341 if !self.checks.is_empty() && self.checks.iter().all(|c| c.baseline) {
342 issues.push(
343 "every check is a baseline capture — nothing evaluates the outcome; add at \
344 least one non-baseline check"
345 .to_string(),
346 );
347 }
348 issues
349 }
350
351 pub fn repair_cosmetic_names(&mut self) {
365 let mut seen = std::collections::HashSet::new();
366 for i in 0..self.checks.len() {
367 let name = self.checks[i].name.trim().to_string();
368 let base = if name.is_empty() || name == "unique_snake_case_label" {
369 format!("check_{}", i + 1)
370 } else {
371 name
372 };
373 let mut candidate = base.clone();
374 let mut k = 2;
375 while !seen.insert(candidate.clone()) {
376 candidate = format!("{base}_{k}");
377 k += 1;
378 }
379 self.checks[i].name = candidate;
380 }
381 }
382
383 pub fn strip_absolute_cd_prefixes(&mut self) {
396 for check in &mut self.checks {
397 check.command = strip_leading_absolute_cd(&check.command);
398 }
399 }
400
401 pub fn strip_exit_masking_pipes(&mut self) {
425 for check in &mut self.checks {
426 if check.expect_exit_zero {
427 check.command = strip_trailing_output_filter(&check.command);
428 }
429 }
430 }
431
432 pub fn render(&self) -> String {
434 let mut out = format!("{}\nChecks:\n", self.description.trim());
435 for c in &self.checks {
436 out.push_str(&format!("- {}: `{}`", c.name, c.command));
437 let mut expects = Vec::new();
438 if c.baseline {
439 expects.push("baseline capture at session start".to_string());
440 }
441 if c.expect_exit_zero {
442 expects.push("exit 0".to_string());
443 }
444 if let Some(s) = &c.output_contains {
445 if let Some(assertion) = s.strip_prefix("$json:") {
446 expects.push(format!("JSON asserts {assertion}"));
447 } else {
448 expects.push(format!("output contains {s:?}"));
449 }
450 }
451 if let Some(diff) = &c.differential {
452 let claim = match &diff.expect {
454 DifferentialExpect::Changed => "changed".to_string(),
455 DifferentialExpect::Unchanged => "unchanged".to_string(),
456 DifferentialExpect::DeltaWithin { min, max } => format!(
457 "delta within [{}, {}]",
458 min.map_or("-inf".to_string(), |m| m.to_string()),
459 max.map_or("+inf".to_string(), |m| m.to_string()),
460 ),
461 };
462 expects.push(format!("vs baseline '{}': {claim}", diff.baseline));
463 }
464 if !expects.is_empty() {
465 out.push_str(&format!(" (expects {})", expects.join(", ")));
466 }
467 out.push('\n');
468 }
469 out
470 }
471}
472
473const OUTPUT_FILTERS: [&str; 3] = ["tail", "head", "cat"];
481
482fn strip_trailing_output_filter(command: &str) -> String {
486 let mut rest = command.trim().to_string();
487 loop {
488 let Some(idx) = last_top_level_pipe(&rest) else {
489 return rest;
490 };
491 let tail_seg = rest[idx + 1..].trim();
492 let head_word = tail_seg.split_whitespace().next().unwrap_or("");
493 if !OUTPUT_FILTERS.contains(&head_word) {
494 return rest;
495 }
496 if tail_seg.contains("&&") || tail_seg.contains(';') || tail_seg.contains("||") {
499 return rest;
500 }
501 rest = rest[..idx].trim_end().to_string();
502 if rest.is_empty() {
503 return command.trim().to_string(); }
505 }
506}
507
508fn last_top_level_pipe(s: &str) -> Option<usize> {
511 let b = s.as_bytes();
512 let (mut sq, mut dq) = (false, false);
513 let mut found = None;
514 let mut i = 0;
515 while i < b.len() {
516 match b[i] {
517 b'\\' => i += 1, b'\'' if !dq => sq = !sq,
519 b'"' if !sq => dq = !dq,
520 b'|' if !sq && !dq => {
521 if b.get(i + 1) == Some(&b'|') {
522 i += 1; } else if i > 0 && b[i - 1] == b'|' {
524 } else {
526 found = Some(i);
527 }
528 }
529 _ => {}
530 }
531 i += 1;
532 }
533 found
534}
535
536fn strip_leading_absolute_cd(command: &str) -> String {
537 let mut rest = command.trim();
538 while let Some(after_cd) = rest.strip_prefix("cd ") {
539 let sep = after_cd
541 .find("&&")
542 .map(|i| (i, 2))
543 .into_iter()
544 .chain(after_cd.find(';').map(|i| (i, 1)))
545 .min_by_key(|(i, _)| *i);
546 let Some((idx, sep_len)) = sep else {
547 break;
548 };
549 let path = after_cd[..idx].trim();
550 if !path.starts_with('/') || path.split_whitespace().count() != 1 {
553 break;
554 }
555 rest = after_cd[idx + sep_len..].trim_start();
556 }
557 rest.to_string()
558}
559
560fn is_toolchain_only(command: &str) -> bool {
567 if command.contains("&&")
569 || command.contains("||")
570 || command.contains('|')
571 || command.contains(';')
572 || command.contains('\n')
573 {
574 return false;
575 }
576 let tokens: Vec<&str> = command.split_whitespace().collect();
577 let [tool, flag] = tokens.as_slice() else {
580 return false;
581 };
582 const TOOLS: &[&str] = &[
583 "cargo", "rustc", "rustup", "node", "npm", "npx", "yarn", "pnpm", "python", "python3",
584 "pip", "pip3", "go", "java", "javac", "ruby", "gem", "dotnet", "deno", "bun", "tsc", "gcc",
585 "clang", "make", "cmake",
586 ];
587 const FLAGS: &[&str] = &["--version", "-V", "-v", "--help", "-h", "version"];
588 TOOLS.contains(tool) && FLAGS.contains(flag)
589}
590
591fn build_contract_prompt(intent: &str, repo_summary: &str, issues: &[String]) -> String {
594 let mut p = format!(
595 "You are deriving an OUTCOME CONTRACT for a coding task: a small set of shell \
596 commands that objectively verify the task is done. The commands run at the root of a \
597 fresh git checkout of the repository, non-interactively, with no TTY.\n\n\
598 Task intent:\n{intent}\n\n\
599 Repository summary:\n{repo_summary}\n\n\
600 Respond with ONLY a JSON object, no prose, no markdown fences, in this shape:\n\
601 {{\n \"description\": \"one-sentence definition of done\",\n \"checks\": [\n \
602 {{\"name\": \"unique_snake_case_label\", \"command\": \"shell command\", \
603 \"expect_exit_zero\": true, \"output_contains\": null, \"timeout_secs\": 120}}\n ]\n}}\n\n\
604 Rules:\n\
605 - Commands run at the repository root ALREADY (the runtime sets the working \
606 directory). Do NOT prefix a command with `cd` into an absolute path, and do NOT \
607 assume a specific mount like `/repo`, `/workspace`, or `/app` — those paths do not \
608 exist here and every such command fails before it runs. Write commands relative to \
609 the repo root (e.g. `python -m pytest tests/test_x.py`, not `cd /repo && python …`).\n\
610 A RELATIVE `cd` is different and is often REQUIRED: when the repository \
611 summary places a build system in a subdirectory, run its commands from there \
612 (e.g. `cd car-rs && cargo test -p some-crate`). The prohibition is on absolute \
613 paths and invented mounts, not on `cd` itself.\n\
614 Do NOT pipe a check into `tail`/`head`/`cat` to shorten output: a pipeline exits with \
615 the LAST command's status, so `pytest … | tail -20` always exits 0 and the check can \
616 never fail. The runtime captures full output itself.\n\
617 - `timeout_secs` must fit the command on a COLD checkout, where nothing is \
618 cached. 120 (the shape example above) suits a fast script or a single unit \
619 test. A compiled-language build or test suite — cargo, go, gradle, swift, \
620 cmake — routinely needs 900–3000. A check killed at its timeout is reported \
621 as a FAILURE, so an under-sized timeout makes the contract permanently red no \
622 matter what the code does; a check that finishes early costs nothing. Size it \
623 generously.\n\
624 - 1 to 5 checks. Each must verify THE TASK ITSELF, not just that the toolchain works \
625 (e.g. `rustc --version` or `cargo --version` prove nothing about the change).\n\
626 - At least one check should exercise the actual new behaviour the intent describes \
627 (run the program/test that the change affects).\n\
628 - For a \"make the failing tests pass\" task, verify by running the failing test's \
629 own FILE (e.g. `python -m pytest tests/test_x.py`), NOT a bespoke reproduction \
630 snippet and NOT a narrow `-k` filter — a hand-written snippet or a guessed filter \
631 routinely passes while the real failing test is untouched, so the session reports \
632 done on an incomplete fix. If specific failing tests are listed below, name them \
633 explicitly. Do NOT run the whole suite (`pytest tests/`): it may contain unrelated \
634 pre-existing failures that your change is not responsible for.\n\
635 - `name` must be a real, descriptive snake_case label unique within the contract — \
636 never the literal placeholder `unique_snake_case_label`.\n\
637 - Every command must run non-interactively and deterministically (no prompts, no \
638 watchers, no servers that don't exit). Use the repo's own build/test commands when \
639 the summary reveals them — a build that must compile the change is a strong check.\n\
640 - `expect_exit_zero: true` (the default) is usually enough. Only set `output_contains` \
641 to a substring you are CERTAIN will appear verbatim in stdout/stderr; if unsure, \
642 leave it null. Do NOT invent example output or placeholder values.\n\
643 - Never use git push, network access, sudo, or anything destructive outside the \
644 checkout. Timeouts are in seconds; keep them realistic for a build.\n"
645 );
646 if !issues.is_empty() {
647 p.push_str("\nYour previous attempt FAILED validation with these issues — fix them:\n");
648 for i in issues {
649 p.push_str(&format!("- {i}\n"));
650 }
651 }
652 p
653}
654
655pub(crate) fn extract_json_object(text: &str) -> Result<Value, String> {
658 let start = text.find('{').ok_or("no JSON object found in output")?;
659 let end = text.rfind('}').ok_or("no closing brace found in output")?;
660 if end < start {
661 return Err("malformed JSON object in output".to_string());
662 }
663 serde_json::from_str(&text[start..=end]).map_err(|e| format!("invalid JSON: {e}"))
664}
665
666pub fn intent_targets_tests(intent: &str) -> bool {
671 let i = intent.to_ascii_lowercase();
672 let mentions_tests = i.contains("test");
673 let mentions_failure = [
674 "fail",
675 "failing",
676 "broken",
677 "passing",
678 "pass the",
679 "make the tests",
680 ]
681 .iter()
682 .any(|k| i.contains(k));
683 mentions_tests && mentions_failure
684}
685
686pub fn parse_test_failures(output: &str) -> Vec<String> {
704 let mut seen = std::collections::HashSet::new();
705 let mut ids = Vec::new();
706 for line in output.lines() {
707 let Some(rest) = line.trim().strip_prefix("FAILED ") else {
708 continue;
709 };
710 let id = rest.split_whitespace().next().unwrap_or("").trim();
711 if id.is_empty() || !id.contains(".py") {
712 continue;
713 }
714 if seen.insert(id.to_string()) {
715 ids.push(id.to_string());
716 }
717 }
718 ids
719}
720
721pub fn summary_with_failures(repo_summary: &str, failing: &[String]) -> String {
725 if failing.is_empty() {
726 return repo_summary.to_string();
727 }
728 let list = failing
729 .iter()
730 .map(|f| format!(" - {f}"))
731 .collect::<Vec<_>>()
732 .join("\n");
733 format!(
734 "{repo_summary}\n\nObserved failing tests (the suite was run before you; these node \
735 ids currently FAIL). Your contract MUST verify that the ones your change addresses \
736 now pass — run them by their exact node id or their file:\n{list}"
737 )
738}
739
740pub struct ContractDraftRequest {
750 pub prompt: String,
752 pub rotate_model: bool,
757}
758
759pub async fn derive_contract<F, Fut>(
778 generate: F,
779 intent: &str,
780 repo_summary: &str,
781 max_attempts: u32,
782 constraints: &[String],
783) -> Result<OutcomeContract, String>
784where
785 F: Fn(ContractDraftRequest) -> Fut + Send + Sync,
786 Fut: Future<Output = Result<String, String>> + Send,
787{
788 let max = max_attempts.max(1);
789 let mut issues: Vec<String> = Vec::new();
790 let mut last_err = String::new();
791 let mut rotate_model = false;
795 let mut best_incomplete: Option<(OutcomeContract, Vec<UngatedConstraint>)> = None;
800
801 for _ in 0..max {
802 let prompt = build_contract_prompt(intent, repo_summary, &issues);
803 let request = ContractDraftRequest {
804 prompt,
805 rotate_model,
806 };
807 let text = match tokio::time::timeout(CONTRACT_GEN_TIMEOUT, generate(request)).await {
808 Ok(Ok(t)) => t,
809 Ok(Err(e)) => {
810 last_err = format!("generation failed: {e}");
815 continue;
816 }
817 Err(_) => {
818 last_err = format!(
830 "contract generation timed out after {}s. The selected model may still \
831 be downloading — a first-use fetch can far exceed this budget. Check \
832 `car models list` for what is actually on disk, pre-pull with \
833 `car models pull <id>`, or sign in for a cloud model that needs no \
834 download.",
835 CONTRACT_GEN_TIMEOUT.as_secs()
836 );
837 continue;
838 }
839 };
840 let value = match extract_json_object(&text) {
841 Ok(v) => v,
842 Err(e) => {
843 rotate_model = true;
852 issues = vec![format!(
853 "output did not parse: {e}. Return ONLY the JSON object."
854 )];
855 last_err = issues.join("; ");
856 continue;
857 }
858 };
859 let mut contract: OutcomeContract = match serde_json::from_value(value) {
860 Ok(c) => c,
861 Err(e) => {
862 rotate_model = true;
867 issues = vec![format!("JSON did not match the contract schema: {e}")];
868 last_err = issues.join("; ");
869 continue;
870 }
871 };
872 rotate_model = false;
876 contract.repair_cosmetic_names();
880 contract.strip_absolute_cd_prefixes();
884 contract.strip_exit_masking_pipes();
887 let problems = contract.validate();
888 if !problems.is_empty() {
889 last_err = problems.join("; ");
890 issues = problems;
891 continue;
892 }
893 let ungated = ungated_constraints(&generate, &contract, constraints).await;
896 if ungated.is_empty() {
897 return Ok(contract);
898 }
899 if best_incomplete
903 .as_ref()
904 .is_none_or(|(_, prior)| ungated.len() < prior.len())
905 {
906 best_incomplete = Some((contract, ungated.clone()));
907 }
908 issues = ungated
913 .iter()
914 .map(|c| match c.coverage {
915 Coverage::Absent => format!(
916 "you DROPPED this constraint, which the operator agreed and which is not \
917 optional: \"{}\". Express it as a CHECK whose command actually verifies \
918 it. Keep every check you already had.",
919 c.text
920 ),
921 Coverage::ProseOnly => format!(
922 "this constraint appears only in `description`, where NOTHING VERIFIES \
923 IT: \"{}\". A contract's force is its checks — prose gates nothing. Add \
924 a check whose command fails when the constraint is violated (a grep, a \
925 test, a diff), and keep every check you already had.",
926 c.text
927 ),
928 })
929 .collect();
930 last_err = format!(
931 "ungated constraint(s): {}",
932 ungated
933 .iter()
934 .map(|c| c.text.as_str())
935 .collect::<Vec<_>>()
936 .join("; ")
937 );
938 }
939 if let Some((mut contract, ungated)) = best_incomplete {
951 contract.description = format!(
952 "{}\n\nNOT VERIFIED BY THIS CONTRACT — these constraints from the discussion are \
953 not gated by any check here, so nothing enforces them (a mention above is not a \
954 check); review them by hand before approving:\n{}",
955 contract.description.trim_end(),
956 ungated
957 .iter()
958 .map(|c| format!(" - {}", c.text))
959 .collect::<Vec<_>>()
960 .join("\n")
961 );
962 return Ok(contract);
963 }
964 Err(format!(
965 "could not derive a valid outcome contract after {max} attempts: {last_err}"
966 ))
967}
968
969#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971enum Coverage {
972 Absent,
974 ProseOnly,
978}
979
980#[derive(Debug, Clone)]
982struct UngatedConstraint {
983 text: String,
984 coverage: Coverage,
985}
986
987async fn ungated_constraints<F, Fut>(
1009 generate: &F,
1010 contract: &OutcomeContract,
1011 constraints: &[String],
1012) -> Vec<UngatedConstraint>
1013where
1014 F: Fn(ContractDraftRequest) -> Fut + Send + Sync,
1015 Fut: Future<Output = Result<String, String>> + Send,
1016{
1017 if constraints.is_empty() {
1018 return Vec::new();
1019 }
1020 let rendered = constraints
1021 .iter()
1022 .enumerate()
1023 .map(|(i, c)| format!("{}. {c}", i + 1))
1024 .collect::<Vec<_>>()
1025 .join("\n");
1026 let contract_json = serde_json::to_string_pretty(contract).unwrap_or_default();
1027 let prompt = format!(
1028 "A verifiable outcome contract was drafted for a coding task. The operator agreed \
1029 these constraints beforehand. A constraint counts as SATISFIED only when some \
1030 check's `command` would actually FAIL if the constraint were violated. Being \
1031 mentioned in `description` does NOT count — the description is prose and runs \
1032 nothing.\n\n\
1033 CONSTRAINTS\n{rendered}\n\n\
1034 CONTRACT\n{contract_json}\n\n\
1035 Return ONLY a JSON object with the 1-based numbers of the constraints that are NOT \
1036 satisfied, split by which failure it is:\n\
1037 {{\"missing\": [1], \"prose_only\": [2]}}\n\n\
1038 - `missing`: the constraint appears nowhere in the contract.\n\
1039 - `prose_only`: the constraint is stated in `description` (or a check NAME) but no \
1040 check command verifies it.\n\n\
1041 Return both arrays empty if every constraint is verified by a check. Judge \
1042 substance, not wording — a check that genuinely verifies the constraint counts even \
1043 if it uses completely different words. Judge the COMMAND, never the name."
1044 );
1045 let request = ContractDraftRequest {
1048 prompt,
1049 rotate_model: false,
1050 };
1051 let text = match tokio::time::timeout(CONTRACT_GEN_TIMEOUT, generate(request)).await {
1052 Ok(Ok(t)) => t,
1053 _ => return Vec::new(),
1054 };
1055 let Ok(value) = extract_json_object(&text) else {
1056 return Vec::new();
1057 };
1058 let indices = |field: &str| -> Vec<usize> {
1059 value
1060 .get(field)
1061 .and_then(Value::as_array)
1062 .map(|a| {
1063 a.iter()
1064 .filter_map(Value::as_u64)
1065 .filter(|n| *n >= 1 && (*n as usize) <= constraints.len())
1066 .map(|n| n as usize - 1)
1067 .collect()
1068 })
1069 .unwrap_or_default()
1070 };
1071 let absent = indices("missing");
1072 let prose_only = indices("prose_only");
1073 (0..constraints.len())
1076 .filter_map(|i| {
1077 let coverage = if absent.contains(&i) {
1078 Coverage::Absent
1079 } else if prose_only.contains(&i) {
1080 Coverage::ProseOnly
1081 } else {
1082 return None;
1083 };
1084 Some(UngatedConstraint {
1085 text: constraints[i].clone(),
1086 coverage,
1087 })
1088 })
1089 .collect()
1090}
1091
1092pub(crate) fn check_assertions(
1093 check: &ContractCheck,
1094 exit_code: Option<i64>,
1095 output: &str,
1096 timed_out: bool,
1097) -> bool {
1098 let exit_ok = !check.expect_exit_zero || exit_code == Some(0);
1099 let output_ok = check
1100 .output_contains
1101 .as_deref()
1102 .map(|assertion| output_assertion_passes(assertion, output))
1103 .unwrap_or(true);
1104 exit_ok && output_ok && !timed_out
1105}
1106
1107fn output_assertion_passes(assertion: &str, output: &str) -> bool {
1108 let Some(expression) = assertion.strip_prefix("$json:") else {
1109 return output.contains(assertion);
1110 };
1111 let Some((pointer, expected)) = expression.split_once('=') else {
1112 return false;
1113 };
1114 let Ok(expected) = serde_json::from_str::<Value>(expected) else {
1115 return false;
1116 };
1117 serde_json::from_str::<Value>(output.trim())
1118 .ok()
1119 .and_then(|value| value.pointer(pointer).cloned())
1120 .is_some_and(|actual| actual == expected)
1121}
1122
1123fn preview(s: &str) -> String {
1126 const CAP: usize = 120;
1127 let trimmed = s.trim();
1128 if trimmed.len() <= CAP {
1129 format!("{trimmed:?}")
1130 } else {
1131 let cut = trimmed
1132 .char_indices()
1133 .take_while(|(i, _)| *i < CAP)
1134 .last()
1135 .map(|(i, c)| i + c.len_utf8())
1136 .unwrap_or(0);
1137 format!("{:?}…", &trimmed[..cut])
1138 }
1139}
1140
1141fn first_number(s: &str) -> Option<f64> {
1149 for token in s.split_whitespace() {
1150 let cleaned: String = token
1151 .trim_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '+' && c != '.')
1152 .replace(',', "");
1153 if cleaned.is_empty() {
1154 continue;
1155 }
1156 if let Ok(n) = cleaned.parse::<f64>() {
1157 return Some(n);
1158 }
1159 }
1160 None
1161}
1162
1163fn evaluate_differential(
1168 diff: &DifferentialCheck,
1169 baselines: &BaselineCaptures,
1170 after_output: &str,
1171) -> Result<(), String> {
1172 let Some(capture) = baselines.get(&diff.baseline) else {
1173 return Err(format!(
1174 "baseline '{}' was never captured — baseline checks run once at session start, and \
1175 no capture by that name reached this evaluation",
1176 diff.baseline
1177 ));
1178 };
1179 if !capture.passed {
1180 return Err(format!(
1181 "baseline '{}' failed at capture time (exit code {:?}), so there is no trustworthy \
1182 before-value to compare against",
1183 diff.baseline, capture.exit_code
1184 ));
1185 }
1186 let before = capture.output_tail.trim().to_string();
1188 let after_tail = super::shell_tool::tail(after_output, 4 * 1024);
1189 let after = after_tail.trim();
1190
1191 match &diff.expect {
1193 DifferentialExpect::Changed => {
1194 if before == after {
1195 Err(format!(
1196 "expected the output to CHANGE from baseline '{}', but it is identical to \
1197 the captured value ({})",
1198 diff.baseline,
1199 preview(&before)
1200 ))
1201 } else {
1202 Ok(())
1203 }
1204 }
1205 DifferentialExpect::Unchanged => {
1206 if before == after {
1207 Ok(())
1208 } else {
1209 Err(format!(
1210 "expected the output to be UNCHANGED from baseline '{}' (the control-group \
1211 claim), but it moved: baseline {} vs current {}",
1212 diff.baseline,
1213 preview(&before),
1214 preview(after)
1215 ))
1216 }
1217 }
1218 DifferentialExpect::DeltaWithin { min, max } => {
1219 let b = first_number(&before).ok_or_else(|| {
1220 format!(
1221 "baseline '{}' captured no numeric value to diff against: {}",
1222 diff.baseline,
1223 preview(&before)
1224 )
1225 })?;
1226 let a = first_number(after).ok_or_else(|| {
1227 format!(
1228 "the check output carries no numeric value to diff: {}",
1229 preview(after)
1230 )
1231 })?;
1232 let delta = a - b;
1233 let lo_ok = min.is_none_or(|m| delta >= m);
1234 let hi_ok = max.is_none_or(|m| delta <= m);
1235 if lo_ok && hi_ok {
1236 Ok(())
1237 } else {
1238 Err(format!(
1239 "delta {delta} from baseline '{}' ({b} -> {a}) is outside the allowed \
1240 bounds [{}, {}]",
1241 diff.baseline,
1242 min.map_or("-inf".to_string(), |m| m.to_string()),
1243 max.map_or("+inf".to_string(), |m| m.to_string()),
1244 ))
1245 }
1246 }
1247 }
1248}
1249
1250async fn run_check(
1255 check: &ContractCheck,
1256 executor: &WorktreeExecutor,
1257 deadline: Option<&SessionDeadline>,
1258 baselines: &BaselineCaptures,
1259) -> CheckResult {
1260 let started = std::time::Instant::now();
1261 let remaining = deadline.and_then(SessionDeadline::remaining_secs);
1262 let ceiling = executor.check_timeout_ceiling();
1266 let clamped = deadline_set_the_timeout(check.timeout_secs, remaining, ceiling);
1267 let timeout = effective_check_timeout(check.timeout_secs, remaining, ceiling);
1268 let outcome = executor
1269 .run_check_shell(&check.command, Some(timeout))
1270 .await;
1271 let duration_ms = started.elapsed().as_millis() as u64;
1272
1273 match outcome {
1274 Ok(v) => {
1275 let exit_code = v.get("exit_code").and_then(Value::as_i64);
1276 let output = v.get("output").and_then(Value::as_str).unwrap_or_default();
1277 let timed_out = v.get("timed_out").and_then(Value::as_bool).unwrap_or(false);
1278 let mut passed = check_assertions(check, exit_code, output, timed_out);
1279 let mut output_tail = super::shell_tool::tail(output, 4 * 1024);
1280 if passed {
1284 if let Some(diff) = &check.differential {
1285 if let Err(msg) = evaluate_differential(diff, baselines, output) {
1286 passed = false;
1287 output_tail = format!("{output_tail}\n[differential] {msg}")
1288 .trim_start()
1289 .to_string();
1290 }
1291 }
1292 }
1293 CheckResult {
1294 name: check.name.clone(),
1295 passed,
1299 exit_code,
1300 output_tail,
1301 duration_ms,
1302 timed_out,
1303 deadline_clamped: clamped,
1304 }
1305 }
1306 Err(e) => CheckResult {
1307 name: check.name.clone(),
1308 passed: false,
1309 exit_code: None,
1310 output_tail: format!("check failed to run: {e}"),
1311 duration_ms,
1312 timed_out: false,
1315 deadline_clamped: clamped,
1316 },
1317 }
1318}
1319
1320pub async fn evaluate_contract(
1325 contract: &OutcomeContract,
1326 executor: &WorktreeExecutor,
1327 sink: &EventSink,
1328) -> Vec<CheckResult> {
1329 evaluate_contract_within(contract, executor, sink, None).await
1330}
1331
1332pub async fn evaluate_contract_with_baselines(
1338 contract: &OutcomeContract,
1339 executor: &WorktreeExecutor,
1340 sink: &EventSink,
1341 baselines: &BaselineCaptures,
1342) -> Vec<CheckResult> {
1343 evaluate_contract_within_baselines(contract, executor, sink, None, baselines).await
1344}
1345
1346pub fn clamp_check_timeout(check_timeout_secs: u64, remaining_secs: Option<u64>) -> u64 {
1366 match remaining_secs {
1367 None => check_timeout_secs,
1368 Some(remaining) => check_timeout_secs.min(remaining),
1369 }
1370}
1371
1372pub(crate) fn deadline_set_the_timeout(
1401 check_timeout_secs: u64,
1402 remaining_secs: Option<u64>,
1403 ceiling_secs: u64,
1404) -> bool {
1405 remaining_secs.is_some_and(|r| r < effective_check_ceiling(check_timeout_secs, ceiling_secs))
1406}
1407
1408pub(crate) fn effective_check_ceiling(check_timeout_secs: u64, ceiling_secs: u64) -> u64 {
1414 check_timeout_secs.min(ceiling_secs)
1415}
1416
1417pub(crate) fn effective_check_timeout(
1427 check_timeout_secs: u64,
1428 remaining_secs: Option<u64>,
1429 ceiling_secs: u64,
1430) -> u64 {
1431 clamp_check_timeout(check_timeout_secs, remaining_secs).clamp(1, ceiling_secs.max(1))
1432}
1433
1434pub async fn evaluate_contract_within(
1455 contract: &OutcomeContract,
1456 executor: &WorktreeExecutor,
1457 sink: &EventSink,
1458 deadline: Option<&SessionDeadline>,
1459) -> Vec<CheckResult> {
1460 evaluate_contract_within_baselines(contract, executor, sink, deadline, &BaselineCaptures::new())
1464 .await
1465}
1466
1467pub async fn evaluate_contract_within_baselines(
1476 contract: &OutcomeContract,
1477 executor: &WorktreeExecutor,
1478 sink: &EventSink,
1479 deadline: Option<&SessionDeadline>,
1480 baselines: &BaselineCaptures,
1481) -> Vec<CheckResult> {
1482 let mut results = Vec::with_capacity(contract.checks.len());
1483 for check in &contract.checks {
1484 sink.emit(CoderEventKind::CheckStarted {
1485 name: check.name.clone(),
1486 });
1487 let result = if check.baseline {
1488 baselines.get(&check.name).cloned().unwrap_or(CheckResult {
1489 name: check.name.clone(),
1490 passed: false,
1491 exit_code: None,
1492 output_tail: "baseline check was never captured — the runtime runs baseline \
1493 checks once at session start, and no capture reached this \
1494 evaluation"
1495 .to_string(),
1496 duration_ms: 0,
1497 timed_out: false,
1498 deadline_clamped: false,
1499 })
1500 } else {
1501 run_check(check, executor, deadline, baselines).await
1502 };
1503 sink.emit(CoderEventKind::CheckCompleted {
1504 result: result.clone(),
1505 });
1506 results.push(result);
1507 }
1508 results
1509}
1510
1511pub async fn evaluate_contract_baseline(
1531 contract: &OutcomeContract,
1532 executor: &WorktreeExecutor,
1533) -> Vec<CheckResult> {
1534 evaluate_contract_baseline_within(contract, executor, None).await
1535}
1536
1537pub async fn evaluate_contract_baseline_within(
1540 contract: &OutcomeContract,
1541 executor: &WorktreeExecutor,
1542 deadline: Option<&SessionDeadline>,
1543) -> Vec<CheckResult> {
1544 let mut results = Vec::with_capacity(contract.checks.len());
1545 let mut captures = BaselineCaptures::new();
1553 for check in &contract.checks {
1554 let result = run_check(check, executor, deadline, &captures).await;
1555 if check.baseline {
1556 captures.insert(check.name.clone(), result.clone());
1557 }
1558 results.push(result);
1559 }
1560 results
1561}
1562
1563pub fn baseline_gates_nothing(results: &[CheckResult]) -> bool {
1576 !results.is_empty() && results.iter().all(|r| r.passed)
1577}
1578
1579pub fn baseline_cannot_run(results: &[CheckResult]) -> Vec<String> {
1597 results
1598 .iter()
1599 .filter(|r| {
1600 !r.timed_out && !r.passed && (r.exit_code.is_none() || r.exit_code == Some(127))
1622 })
1623 .map(|r| r.name.clone())
1624 .collect()
1625}
1626
1627#[cfg(test)]
1628mod unrunnable_tests {
1629 use super::*;
1630
1631 fn result(name: &str, passed: bool, exit_code: Option<i64>) -> CheckResult {
1632 CheckResult {
1633 name: name.into(),
1634 passed,
1635 exit_code,
1636 output_tail: String::new(),
1637 duration_ms: 0,
1638 timed_out: false,
1639 deadline_clamped: false,
1640 }
1641 }
1642
1643 #[test]
1659 fn a_timed_out_check_is_not_unrunnable() {
1660 let mut timed_out = result("slow_build", false, None);
1661 timed_out.timed_out = true;
1662 timed_out.duration_ms = 120_009;
1663 assert!(
1664 baseline_cannot_run(&[timed_out]).is_empty(),
1665 "a check killed by a clock is not a missing command"
1666 );
1667 }
1668
1669 #[test]
1672 fn a_spawn_failure_is_still_unrunnable_alongside_a_timeout() {
1673 let mut timed_out = result("slow_build", false, None);
1674 timed_out.timed_out = true;
1675 assert_eq!(
1676 baseline_cannot_run(&[timed_out, result("never_spawned", false, None)]),
1677 vec!["never_spawned".to_string()]
1678 );
1679 }
1680
1681 #[test]
1682 fn an_ordinary_red_check_is_not_unrunnable() {
1683 assert!(baseline_cannot_run(&[result("tests", false, Some(1))]).is_empty());
1686 }
1687
1688 #[test]
1689 fn a_missing_command_is_unrunnable() {
1690 assert_eq!(
1694 baseline_cannot_run(&[result("tests", false, Some(127))]),
1695 vec!["tests".to_string()]
1696 );
1697 }
1698
1699 #[test]
1700 fn a_check_that_never_spawned_is_unrunnable() {
1701 assert_eq!(
1704 baseline_cannot_run(&[result("tests", false, None)]),
1705 vec!["tests".to_string()]
1706 );
1707 }
1708
1709 #[test]
1710 fn a_passing_check_is_never_unrunnable() {
1711 assert!(baseline_cannot_run(&[result("tests", true, Some(127))]).is_empty());
1714 }
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719
1720 #[test]
1726 fn parse_test_failures_pulls_node_ids_from_pytest_summary() {
1727 let out = "=========================== short test summary info ============================
1728FAILED tests/test_basic.py::test_session_using_session_settings - AssertionError
1729FAILED tests/test_reqctx.py::test_environ_for_valid_idna - ValueError: x
1730ERROR tests/test_instance_config.py::test_installed_package_paths[True] - AttributeError
1731FAILED tests/test_basic.py::test_session_using_session_settings - AssertionError
17321 failed in 0.10s";
1733 let ids = parse_test_failures(out);
1734 assert_eq!(
1735 ids,
1736 vec![
1737 "tests/test_basic.py::test_session_using_session_settings".to_string(),
1738 "tests/test_reqctx.py::test_environ_for_valid_idna".to_string(),
1739 ],
1740 "FAILED node ids only, deduped, order-preserved — the ERROR \
1741 (collection/environment drift) is excluded"
1742 );
1743 assert!(parse_test_failures("125 passed in 0.12s").is_empty());
1745 assert!(parse_test_failures("FAILED something-weird - boom").is_empty());
1747 }
1748
1749 #[test]
1750 fn intent_targets_tests_fires_only_on_test_fixing_intents() {
1751 assert!(intent_targets_tests(
1752 "In this repository, the tests fail because of a bug. Fix the source so the tests pass."
1753 ));
1754 assert!(intent_targets_tests("make the failing tests pass"));
1755 assert!(!intent_targets_tests("Add a --json flag to the CLI"));
1757 assert!(!intent_targets_tests("Refactor the parser for clarity"));
1758 }
1759
1760 #[test]
1761 fn summary_with_failures_injects_observed_ids_and_is_a_noop_when_empty() {
1762 let base = "Top-level entries: src, tests";
1763 assert_eq!(summary_with_failures(base, &[]), base);
1764 let with = summary_with_failures(
1765 base,
1766 &["tests/test_basic.py::test_session_using_session_settings".to_string()],
1767 );
1768 assert!(with.contains("Observed failing tests"));
1769 assert!(with.contains("tests/test_basic.py::test_session_using_session_settings"));
1770 assert!(with.starts_with(base));
1771 }
1772
1773 #[test]
1779 fn strips_trailing_output_filters_that_mask_the_exit_code() {
1780 let mut c = OutcomeContract {
1781 description: "tests pass".into(),
1782 checks: vec![
1783 ContractCheck {
1784 name: "run_full_test_suite".into(),
1785 command: "python -m pytest tests/ -x -q 2>&1 | tail -20".into(),
1786 expect_exit_zero: true,
1787 output_contains: None,
1788 timeout_secs: 120,
1789 baseline: false,
1790 differential: None,
1791 },
1792 ContractCheck {
1793 name: "chained".into(),
1794 command: "pytest -q | head -n 50 | tail -5".into(),
1795 expect_exit_zero: true,
1796 output_contains: None,
1797 timeout_secs: 120,
1798 baseline: false,
1799 differential: None,
1800 },
1801 ],
1802 };
1803 c.strip_exit_masking_pipes();
1804 assert_eq!(c.checks[0].command, "python -m pytest tests/ -x -q 2>&1");
1806 assert_eq!(c.checks[1].command, "pytest -q");
1807 }
1808
1809 #[test]
1812 fn leaves_meaningful_pipes_and_or_lists_alone() {
1813 let keep = [
1814 "pytest -q | grep -q PASSED",
1815 "cmd || echo fallback",
1816 "python -c \"print('a|b')\"",
1817 "pytest -q",
1818 ];
1819 for cmd in keep {
1820 let mut c = OutcomeContract {
1821 description: "d".into(),
1822 checks: vec![ContractCheck {
1823 name: "k".into(),
1824 command: cmd.into(),
1825 expect_exit_zero: true,
1826 output_contains: None,
1827 timeout_secs: 120,
1828 baseline: false,
1829 differential: None,
1830 }],
1831 };
1832 c.strip_exit_masking_pipes();
1833 assert_eq!(c.checks[0].command, cmd, "must not rewrite: {cmd}");
1834 }
1835 }
1836 use super::*;
1837 use std::sync::atomic::{AtomicUsize, Ordering};
1838
1839 const VALID: &str = r#"{
1840 "description": "file exists",
1841 "checks": [{"name": "exists", "command": "test -f x.txt"}]
1842 }"#;
1843
1844 #[test]
1845 fn prompt_steers_toward_verifying_the_task_and_real_labels() {
1846 let p = build_contract_prompt(
1847 "add a --version flag",
1848 "Top-level entries: Cargo.toml, src\nBuild systems detected: Rust (cargo)",
1849 &[],
1850 );
1851 assert!(p.contains("add a --version flag"));
1853 assert!(p.contains("Rust (cargo)"));
1854 assert!(p.contains("verify THE TASK ITSELF"));
1856 assert!(
1857 p.contains("rustc --version"),
1858 "names the toolchain-only anti-pattern"
1859 );
1860 assert!(p.contains("never the literal placeholder"));
1861 assert!(p.contains("non-interactively"));
1863 assert!(
1864 p.contains("CERTAIN will appear"),
1865 "output_contains caution present"
1866 );
1867 assert!(p.contains("no markdown fences"));
1868 }
1869
1870 #[test]
1871 fn repair_prompt_appends_prior_issues() {
1872 let p = build_contract_prompt("t", "r", &["check 'a' has an empty command".into()]);
1873 assert!(p.contains("FAILED validation"));
1874 assert!(p.contains("empty command"));
1875 }
1876
1877 #[tokio::test]
1878 async fn derives_on_first_valid_attempt() {
1879 let c = derive_contract(
1880 |_r| async { Ok::<_, String>(VALID.into()) },
1881 "make x",
1882 "repo",
1883 3,
1884 &[],
1885 )
1886 .await
1887 .unwrap();
1888 assert_eq!(c.checks.len(), 1);
1889 assert!(c.checks[0].expect_exit_zero, "default applies");
1890 assert_eq!(c.checks[0].timeout_secs, 120);
1891 }
1892
1893 #[tokio::test(start_paused = true)]
1894 async fn times_out_when_generation_hangs() {
1895 let err = derive_contract(
1900 |_r| async {
1901 tokio::time::sleep(std::time::Duration::from_secs(10_000)).await;
1902 Ok::<_, String>(VALID.into())
1903 },
1904 "make x",
1905 "repo",
1906 1,
1907 &[],
1908 )
1909 .await
1910 .unwrap_err();
1911 assert!(
1912 err.contains("timed out"),
1913 "expected timeout error, got: {err}"
1914 );
1915 }
1916
1917 #[tokio::test]
1918 async fn repairs_fenced_and_chatty_output() {
1919 let fenced = format!("Sure! Here is the contract:\n```json\n{VALID}\n```");
1920 let c = derive_contract(
1921 |_r| {
1922 let text = fenced.clone();
1923 async move { Ok::<_, String>(text) }
1924 },
1925 "x",
1926 "r",
1927 3,
1928 &[],
1929 )
1930 .await
1931 .unwrap();
1932 assert_eq!(c.checks[0].name, "exists");
1933 }
1934
1935 #[tokio::test]
1936 async fn invalid_then_repaired() {
1937 let calls = AtomicUsize::new(0);
1938 let c = derive_contract(
1939 |req: ContractDraftRequest| {
1940 let prompt = req.prompt;
1941 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
1942 async move {
1943 if n == 1 {
1944 Ok::<_, String>(r#"{"description": "no checks", "checks": []}"#.into())
1945 } else {
1946 assert!(
1947 prompt.contains("FAILED validation"),
1948 "repair prompt carries issues"
1949 );
1950 Ok(VALID.into())
1951 }
1952 }
1953 },
1954 "x",
1955 "r",
1956 3,
1957 &[],
1958 )
1959 .await
1960 .unwrap();
1961 assert_eq!(c.checks.len(), 1);
1962 }
1963
1964 #[tokio::test]
1965 async fn gives_up_with_error_after_max() {
1966 let err = derive_contract(
1967 |_r| async { Ok::<_, String>("not json at all".into()) },
1968 "x",
1969 "r",
1970 2,
1971 &[],
1972 )
1973 .await
1974 .unwrap_err();
1975 assert!(err.contains("after 2 attempts"), "{err}");
1976 }
1977
1978 fn rotation_recorder() -> std::sync::Arc<std::sync::Mutex<Vec<bool>>> {
1983 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))
1984 }
1985
1986 #[tokio::test]
1993 async fn rotates_model_after_unparseable_output() {
1994 let rotations = rotation_recorder();
1995 let seen = rotations.clone();
1996 let calls = AtomicUsize::new(0);
1997 let c = derive_contract(
1998 move |req: ContractDraftRequest| {
1999 seen.lock().unwrap().push(req.rotate_model);
2000 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2001 async move {
2002 if n == 1 {
2003 Ok::<_, String>(
2006 "Sure! Here is the outcome contract:\n\
2007 {\"description\": \"tests pass\", \"checks\": ["
2008 .to_string(),
2009 )
2010 } else {
2011 Ok(VALID.into())
2012 }
2013 }
2014 },
2015 "x",
2016 "r",
2017 3,
2018 &[],
2019 )
2020 .await
2021 .unwrap();
2022 assert_eq!(c.checks.len(), 1);
2023 assert_eq!(
2024 *rotations.lock().unwrap(),
2025 vec![false, true],
2026 "only the attempt AFTER the unusable reply asks routing to rotate"
2027 );
2028 }
2029
2030 #[tokio::test]
2034 async fn rotates_model_after_output_that_is_json_but_not_a_contract() {
2035 let rotations = rotation_recorder();
2036 let seen = rotations.clone();
2037 let calls = AtomicUsize::new(0);
2038 let c = derive_contract(
2039 move |req: ContractDraftRequest| {
2040 seen.lock().unwrap().push(req.rotate_model);
2041 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2042 async move {
2043 if n == 1 {
2044 Ok::<_, String>(
2045 r#"{"result": "ok", "steps": ["run the tests"]}"#.to_string(),
2046 )
2047 } else {
2048 Ok(VALID.into())
2049 }
2050 }
2051 },
2052 "x",
2053 "r",
2054 3,
2055 &[],
2056 )
2057 .await
2058 .unwrap();
2059 assert_eq!(c.checks.len(), 1);
2060 assert_eq!(
2061 *rotations.lock().unwrap(),
2062 vec![false, true],
2063 "a schema mismatch is a JSON-shape failure and rotates too"
2064 );
2065 }
2066
2067 #[tokio::test]
2072 async fn validation_failure_does_not_rotate_model() {
2073 let rotations = rotation_recorder();
2074 let seen = rotations.clone();
2075 let calls = AtomicUsize::new(0);
2076 let c = derive_contract(
2077 move |req: ContractDraftRequest| {
2078 seen.lock().unwrap().push(req.rotate_model);
2079 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2080 async move {
2081 if n == 1 {
2082 Ok::<_, String>(r#"{"description": "no checks", "checks": []}"#.to_string())
2083 } else {
2084 Ok(VALID.into())
2085 }
2086 }
2087 },
2088 "x",
2089 "r",
2090 3,
2091 &[],
2092 )
2093 .await
2094 .unwrap();
2095 assert_eq!(c.checks.len(), 1);
2096 assert_eq!(
2097 *rotations.lock().unwrap(),
2098 vec![false, false],
2099 "a contract that parsed but failed validate() must stay on its model"
2100 );
2101 }
2102
2103 #[tokio::test]
2107 async fn rotation_clears_once_an_attempt_parses() {
2108 let rotations = rotation_recorder();
2109 let seen = rotations.clone();
2110 let calls = AtomicUsize::new(0);
2111 let c = derive_contract(
2112 move |req: ContractDraftRequest| {
2113 seen.lock().unwrap().push(req.rotate_model);
2114 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2115 async move {
2116 match n {
2117 1 => Ok::<_, String>("I'd be happy to help!".to_string()),
2119 2 => Ok(r#"{"description": "no checks", "checks": []}"#.to_string()),
2122 _ => Ok(VALID.into()),
2123 }
2124 }
2125 },
2126 "x",
2127 "r",
2128 3,
2129 &[],
2130 )
2131 .await
2132 .unwrap();
2133 assert_eq!(c.checks.len(), 1);
2134 assert_eq!(
2135 *rotations.lock().unwrap(),
2136 vec![false, true, false],
2137 "rotation is set by the shape failure and cleared by the next parse"
2138 );
2139 }
2140
2141 #[test]
2142 fn is_toolchain_only_flags_bare_version_probes_only() {
2143 for c in [
2145 "cargo --version",
2146 "cargo -V",
2147 "rustc --version",
2148 "node -v",
2149 "npm --version",
2150 "python3 --version",
2151 "go version",
2152 "make --help",
2153 ] {
2154 assert!(is_toolchain_only(c), "should flag `{c}`");
2155 }
2156 for c in [
2158 "cargo build",
2159 "cargo test",
2160 "cargo run -- --version",
2161 "cargo run --release -- --version",
2162 "./target/debug/greeter --version",
2163 "cargo --version && cargo build",
2164 "test -f src/main.rs",
2165 "grep -q version Cargo.toml",
2166 "rustc src/main.rs -o /tmp/x",
2167 ] {
2168 assert!(!is_toolchain_only(c), "should NOT flag `{c}`");
2169 }
2170 }
2171
2172 #[test]
2173 fn validate_rejects_toolchain_only_and_placeholder_name() {
2174 let c = OutcomeContract {
2175 description: "d".into(),
2176 checks: vec![ContractCheck {
2177 name: "unique_snake_case_label".into(),
2180 command: "cargo --version".into(),
2181 expect_exit_zero: true,
2182 output_contains: None,
2183 timeout_secs: 120,
2184 baseline: false,
2185 differential: None,
2186 }],
2187 };
2188 let issues = c.validate();
2189 assert!(
2190 issues.iter().any(|i| i.contains("placeholder name")),
2191 "{issues:?}"
2192 );
2193 assert!(
2194 issues.iter().any(|i| i.contains("toolchain-only no-op")),
2195 "{issues:?}"
2196 );
2197 }
2198
2199 #[test]
2200 fn repair_cosmetic_names_fixes_placeholder_empty_and_duplicates() {
2201 let mk = |name: &str, cmd: &str| ContractCheck {
2202 name: name.into(),
2203 command: cmd.into(),
2204 expect_exit_zero: true,
2205 output_contains: None,
2206 timeout_secs: 60,
2207 baseline: false,
2208 differential: None,
2209 };
2210 let mut c = OutcomeContract {
2211 description: "d".into(),
2212 checks: vec![
2213 mk("unique_snake_case_label", "pytest a"),
2214 mk("", "pytest b"),
2215 mk("run_tests", "pytest c"),
2216 mk("run_tests", "pytest d"),
2217 ],
2218 };
2219 c.repair_cosmetic_names();
2220 let names: Vec<&str> = c.checks.iter().map(|x| x.name.as_str()).collect();
2221 assert_eq!(
2222 names,
2223 vec!["check_1", "check_2", "run_tests", "run_tests_2"]
2224 );
2225 assert_eq!(c.checks[0].command, "pytest a");
2226 assert!(c.validate().is_empty(), "{:?}", c.validate());
2227 }
2228
2229 #[test]
2230 fn strip_absolute_cd_prefixes_drops_repo_but_keeps_relative_and_body() {
2231 let mk = |cmd: &str| ContractCheck {
2232 name: "c".into(),
2233 command: cmd.into(),
2234 expect_exit_zero: true,
2235 output_contains: None,
2236 timeout_secs: 60,
2237 baseline: false,
2238 differential: None,
2239 };
2240 let mut c = OutcomeContract {
2241 description: "d".into(),
2242 checks: vec![
2243 mk("cd /repo && python -m pytest tests/ -v 2>&1"),
2245 mk("cd /workspace ; ./run.sh"),
2247 mk("cd subpkg && cargo test"),
2249 mk("python -m pytest -q tests/test_x.py"),
2251 mk("echo hi && cd /repo && pytest"),
2253 ],
2254 };
2255 c.strip_absolute_cd_prefixes();
2256 let cmds: Vec<&str> = c.checks.iter().map(|x| x.command.as_str()).collect();
2257 assert_eq!(
2258 cmds,
2259 vec![
2260 "python -m pytest tests/ -v 2>&1",
2261 "./run.sh",
2262 "cd subpkg && cargo test",
2263 "python -m pytest -q tests/test_x.py",
2264 "echo hi && cd /repo && pytest",
2265 ]
2266 );
2267 }
2268
2269 #[tokio::test]
2270 async fn derive_strips_hallucinated_repo_cd_first_try() {
2271 let with_repo_cd = r#"{"description":"tests pass","checks":[
2275 {"name":"run_tests","command":"cd /repo && python -m pytest -q tests/test_x.py"}]}"#;
2276 let c = derive_contract(
2277 |_r: ContractDraftRequest| async move { Ok::<_, String>(with_repo_cd.into()) },
2278 "fix the bug so pytest passes",
2279 "Python",
2280 3,
2281 &[],
2282 )
2283 .await
2284 .unwrap();
2285 assert_eq!(
2286 c.checks[0].command, "python -m pytest -q tests/test_x.py",
2287 "the hallucinated `cd /repo &&` prefix must be stripped"
2288 );
2289 }
2290
2291 #[tokio::test]
2292 async fn derive_succeeds_first_try_when_model_only_leaves_placeholder_name() {
2293 let calls = AtomicUsize::new(0);
2294 let placeholder_named = r#"{"description":"tests pass","checks":[
2295 {"name":"unique_snake_case_label","command":"python3 -m pytest -q"}]}"#;
2296 let c = derive_contract(
2297 |_r: ContractDraftRequest| {
2298 calls.fetch_add(1, Ordering::SeqCst);
2299 async move { Ok::<_, String>(placeholder_named.into()) }
2300 },
2301 "fix the bug so pytest passes",
2302 "Python",
2303 3,
2304 &[],
2305 )
2306 .await
2307 .unwrap();
2308 assert_eq!(calls.load(Ordering::SeqCst), 1, "no repair attempt needed");
2309 assert_eq!(c.checks[0].name, "check_1");
2310 assert_eq!(c.checks[0].command, "python3 -m pytest -q");
2311 }
2312
2313 #[tokio::test]
2314 async fn derive_repairs_a_toolchain_only_first_attempt() {
2315 let calls = AtomicUsize::new(0);
2316 let toolchain_only = r#"{"description":"v","checks":[
2317 {"name":"unique_snake_case_label","command":"cargo --version"}]}"#;
2318 let real = r#"{"description":"v","checks":[
2319 {"name":"version_flag_prints","command":"cargo run -- --version"}]}"#;
2320 let c = derive_contract(
2321 |req: ContractDraftRequest| {
2322 let prompt = req.prompt;
2323 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
2324 async move {
2325 if n == 1 {
2326 Ok::<_, String>(toolchain_only.into())
2327 } else {
2328 assert!(prompt.contains("toolchain-only no-op"), "{prompt}");
2333 assert!(!prompt.contains("placeholder name"), "{prompt}");
2334 Ok(real.into())
2335 }
2336 }
2337 },
2338 "add a --version flag",
2339 "Rust (cargo)",
2340 3,
2341 &[],
2342 )
2343 .await
2344 .unwrap();
2345 assert_eq!(c.checks[0].command, "cargo run -- --version");
2346 assert_eq!(calls.load(Ordering::SeqCst), 2, "took exactly one repair");
2347 }
2348
2349 #[test]
2350 fn validate_catches_empty_and_duplicate_and_assertless() {
2351 let c = OutcomeContract {
2352 description: "d".into(),
2353 checks: vec![
2354 ContractCheck {
2355 name: "a".into(),
2356 command: "true".into(),
2357 expect_exit_zero: false,
2358 output_contains: None,
2359 timeout_secs: 5,
2360 baseline: false,
2361 differential: None,
2362 },
2363 ContractCheck {
2364 name: "a".into(),
2365 command: "".into(),
2366 expect_exit_zero: true,
2367 output_contains: None,
2368 timeout_secs: 5,
2369 baseline: false,
2370 differential: None,
2371 },
2372 ],
2373 };
2374 let issues = c.validate();
2375 assert!(issues.iter().any(|i| i.contains("asserts nothing")));
2376 assert!(issues.iter().any(|i| i.contains("empty command")));
2377 assert!(issues.iter().any(|i| i.contains("duplicate")));
2378 }
2379
2380 #[tokio::test]
2381 async fn evaluate_passes_and_fails_checks_in_a_real_dir() {
2382 let dir = tempfile::tempdir().unwrap();
2383 std::fs::write(dir.path().join("present.txt"), "hello needle").unwrap();
2384 let exec = WorktreeExecutor::new(dir.path());
2385 let sink = EventSink::test_sink();
2386 let contract = OutcomeContract {
2387 description: "d".into(),
2388 checks: vec![
2389 ContractCheck {
2390 name: "exists".into(),
2391 command: crate::coder::test_cmds::file_exists("present.txt"),
2392 expect_exit_zero: true,
2393 output_contains: None,
2394 timeout_secs: 10,
2395 baseline: false,
2396 differential: None,
2397 },
2398 ContractCheck {
2399 name: "content".into(),
2400 command: crate::coder::test_cmds::cat("present.txt"),
2401 expect_exit_zero: true,
2402 output_contains: Some("needle".into()),
2403 timeout_secs: 10,
2404 baseline: false,
2405 differential: None,
2406 },
2407 ContractCheck {
2408 name: "missing".into(),
2409 command: crate::coder::test_cmds::file_exists("absent.txt"),
2410 expect_exit_zero: true,
2411 output_contains: None,
2412 timeout_secs: 10,
2413 baseline: false,
2414 differential: None,
2415 },
2416 ],
2417 };
2418 let results = evaluate_contract(&contract, &exec, &sink).await;
2419 assert_eq!(results.len(), 3, "all checks run even after a failure");
2420 assert!(results[0].passed);
2421 assert!(results[1].passed);
2422 assert!(!results[2].passed);
2423 assert_eq!(results[2].exit_code, Some(1));
2424 }
2425
2426 #[tokio::test]
2427 async fn evaluate_fails_on_missing_substring() {
2428 let dir = tempfile::tempdir().unwrap();
2429 let exec = WorktreeExecutor::new(dir.path());
2430 let sink = EventSink::test_sink();
2431 let contract = OutcomeContract {
2432 description: "d".into(),
2433 checks: vec![ContractCheck {
2434 name: "needle".into(),
2435 command: "echo haystack".into(),
2436 expect_exit_zero: true,
2437 output_contains: Some("needle".into()),
2438 timeout_secs: 10,
2439 baseline: false,
2440 differential: None,
2441 }],
2442 };
2443 let results = evaluate_contract(&contract, &exec, &sink).await;
2444 assert!(!results[0].passed, "exit 0 but substring missing must fail");
2445 assert_eq!(results[0].exit_code, Some(0));
2446 }
2447
2448 fn check(name: &str, command: &str) -> ContractCheck {
2451 ContractCheck {
2452 name: name.into(),
2453 command: command.into(),
2454 expect_exit_zero: true,
2455 output_contains: None,
2456 timeout_secs: 10,
2457 baseline: false,
2458 differential: None,
2459 }
2460 }
2461
2462 #[test]
2467 fn a_check_never_outlives_the_session_budget() {
2468 assert_eq!(clamp_check_timeout(900, None), 900);
2470 assert_eq!(clamp_check_timeout(900, Some(3600)), 900);
2472 assert_eq!(clamp_check_timeout(900, Some(30)), 30);
2474 assert_eq!(clamp_check_timeout(900, Some(0)), 0);
2477 }
2478
2479 #[tokio::test]
2487 async fn an_exhausted_session_budget_cuts_the_baseline_short() {
2488 let dir = tempfile::tempdir().unwrap();
2489 let exec = WorktreeExecutor::new(dir.path());
2490 let contract = OutcomeContract {
2491 description: "d".into(),
2492 checks: vec![ContractCheck {
2493 name: "slow".into(),
2494 command: "sleep 5".into(),
2495 expect_exit_zero: true,
2496 output_contains: None,
2497 timeout_secs: 30,
2498 baseline: false,
2499 differential: None,
2500 }],
2501 };
2502
2503 let spent = SessionDeadline::new(Some(0));
2504 let started = std::time::Instant::now();
2505 let baseline = evaluate_contract_baseline_within(&contract, &exec, Some(&spent)).await;
2506 let elapsed = started.elapsed();
2507
2508 assert!(!baseline[0].passed, "a cut-off check is not a pass");
2509 assert!(
2510 elapsed < std::time::Duration::from_secs(4),
2511 "the baseline ran for {elapsed:?}; a spent session budget must cut it short"
2512 );
2513 }
2514
2515 #[tokio::test]
2523 async fn a_check_starved_by_the_session_clock_is_marked_as_such() {
2524 let dir = tempfile::tempdir().unwrap();
2525 let exec = WorktreeExecutor::new(dir.path());
2526 let slow = ContractCheck {
2527 name: "suite".into(),
2528 command: "sleep 5".into(),
2529 expect_exit_zero: true,
2530 output_contains: None,
2531 timeout_secs: 900,
2533 baseline: false,
2534 differential: None,
2535 };
2536 let spent = SessionDeadline::new(Some(0));
2537
2538 let r = run_check(&slow, &exec, Some(&spent), &BaselineCaptures::new()).await;
2539
2540 assert!(!r.passed, "a cut-off check is still not a pass");
2541 assert!(r.timed_out, "it was killed at a timeout, not exited");
2542 assert!(
2543 r.deadline_clamped,
2544 "the timeout it died at was the session's leftover budget, not its own 900s"
2545 );
2546 assert!(
2547 r.starved_by_deadline(),
2548 "so this is not a verdict on the work"
2549 );
2550 }
2551
2552 #[tokio::test]
2556 async fn a_check_that_blows_its_own_timeout_is_not_starved() {
2557 let dir = tempfile::tempdir().unwrap();
2558 let exec = WorktreeExecutor::new(dir.path());
2559 let hang = ContractCheck {
2560 name: "suite".into(),
2561 command: "sleep 5".into(),
2562 expect_exit_zero: true,
2563 output_contains: None,
2564 timeout_secs: 1,
2565 baseline: false,
2566 differential: None,
2567 };
2568 let plenty = SessionDeadline::new(Some(3600));
2569
2570 let r = run_check(&hang, &exec, Some(&plenty), &BaselineCaptures::new()).await;
2571
2572 assert!(!r.passed);
2573 assert!(r.timed_out, "it ran past its own one-second ceiling");
2574 assert!(
2575 !r.deadline_clamped,
2576 "the session had an hour left — nothing was clamped"
2577 );
2578 assert!(
2579 !r.starved_by_deadline(),
2580 "a genuine hang must stay a red verdict"
2581 );
2582 }
2583
2584 #[test]
2596 fn the_clamp_flag_is_derived_against_the_shell_ceiling_not_the_declared_one() {
2597 use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
2598 assert_eq!(
2599 MAX_SHELL_TIMEOUT_SECS, 600,
2600 "the cases below are read at 600"
2601 );
2602 let default = MAX_SHELL_TIMEOUT_SECS;
2603
2604 assert!(deadline_set_the_timeout(900, Some(500), default));
2607
2608 assert!(
2611 !deadline_set_the_timeout(900, Some(700), default),
2612 "600 < remaining < timeout_secs is the shell ceiling cutting the \
2613 check, not the session clock — marking it clamped would let a hang \
2614 report as session_wall_exhausted"
2615 );
2616
2617 assert!(!deadline_set_the_timeout(900, Some(3600), default));
2619 assert!(!deadline_set_the_timeout(900, Some(600), default));
2621 assert!(deadline_set_the_timeout(120, Some(30), default));
2623 assert!(!deadline_set_the_timeout(120, Some(200), default));
2624 assert!(!deadline_set_the_timeout(900, None, default));
2626
2627 assert!(
2633 deadline_set_the_timeout(900, Some(700), 900),
2634 "with the ceiling raised to the declared timeout, a shorter remaining \
2635 budget is the session clock cutting the check"
2636 );
2637 assert!(!deadline_set_the_timeout(900, Some(950), 3600));
2640 assert!(deadline_set_the_timeout(900, Some(800), 3600));
2641 }
2642
2643 #[test]
2647 fn the_effective_check_timeout_composes_budget_then_ceiling() {
2648 assert_eq!(effective_check_timeout(900, None, 600), 600);
2650 assert_eq!(effective_check_timeout(900, Some(3600), 600), 600);
2651 assert_eq!(effective_check_timeout(900, Some(120), 600), 120);
2653 assert_eq!(effective_check_timeout(900, Some(3600), 900), 900);
2655 assert_eq!(effective_check_timeout(900, None, 1200), 900);
2656 assert_eq!(effective_check_timeout(900, Some(0), 600), 1);
2659 assert_eq!(effective_check_timeout(900, None, 0), 1);
2660 }
2661
2662 #[tokio::test]
2670 async fn the_check_ceiling_bounds_the_process_not_just_the_flag() {
2671 let dir = tempfile::tempdir().unwrap();
2672 let exec = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(1);
2673 assert_eq!(exec.check_timeout_ceiling(), 1);
2674 let slow = ContractCheck {
2675 name: "slow".into(),
2676 command: "sleep 5".into(),
2677 expect_exit_zero: true,
2678 output_contains: None,
2679 timeout_secs: 30,
2680 baseline: false,
2681 differential: None,
2682 };
2683 let plenty = SessionDeadline::new(Some(3600));
2684
2685 let r = run_check(&slow, &exec, Some(&plenty), &BaselineCaptures::new()).await;
2686
2687 assert!(!r.passed);
2688 assert!(
2689 r.timed_out,
2690 "the 1s check ceiling killed it, not the declared 30s"
2691 );
2692 assert!(
2693 !r.deadline_clamped,
2694 "the session had an hour left — the check ceiling cut it"
2695 );
2696 assert!(r.duration_ms < 5_000, "it must not have slept the full 5s");
2697 }
2698
2699 #[test]
2702 fn a_zero_check_ceiling_is_floored_not_honored() {
2703 let dir = tempfile::tempdir().unwrap();
2704 let exec = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(0);
2705 assert_eq!(exec.check_timeout_ceiling(), 1);
2706 }
2707
2708 #[test]
2714 fn raising_the_check_ceiling_leaves_the_model_facing_shell_alone() {
2715 use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
2716 let dir = tempfile::tempdir().unwrap();
2717 let exec = WorktreeExecutor::new(dir.path());
2718 assert_eq!(exec.check_timeout_ceiling(), MAX_SHELL_TIMEOUT_SECS);
2719
2720 let raised = WorktreeExecutor::new(dir.path()).with_check_timeout_ceiling(3600);
2721 assert_eq!(raised.check_timeout_ceiling(), 3600);
2722 let shell_def = WorktreeExecutor::tool_defs()
2723 .into_iter()
2724 .find(|d| d["name"] == "shell")
2725 .expect("shell tool is advertised");
2726 assert!(
2727 shell_def["parameters"]["properties"]["timeout_secs"]["description"]
2728 .as_str()
2729 .unwrap()
2730 .contains("max 600"),
2731 "the model-facing description still promises 600"
2732 );
2733 }
2734
2735 #[tokio::test]
2738 async fn a_healthy_session_budget_does_not_truncate_the_baseline() {
2739 let dir = tempfile::tempdir().unwrap();
2740 let exec = WorktreeExecutor::new(dir.path());
2741 let contract = OutcomeContract {
2742 description: "d".into(),
2743 checks: vec![check("quick", "exit 0")],
2744 };
2745 let plenty = SessionDeadline::new(Some(3600));
2746 let baseline = evaluate_contract_baseline_within(&contract, &exec, Some(&plenty)).await;
2747 assert!(baseline[0].passed);
2748 }
2749
2750 #[tokio::test]
2754 async fn an_all_green_baseline_is_flagged_as_gating_nothing() {
2755 let dir = tempfile::tempdir().unwrap();
2756 let exec = WorktreeExecutor::new(dir.path());
2757 let contract = OutcomeContract {
2758 description: "d".into(),
2759 checks: vec![check("a", "exit 0"), check("b", "exit 0")],
2760 };
2761
2762 let baseline = evaluate_contract_baseline(&contract, &exec).await;
2763 assert_eq!(baseline.len(), 2);
2764 assert!(baseline.iter().all(|r| r.passed));
2765 assert!(baseline_gates_nothing(&baseline));
2766 }
2767
2768 #[tokio::test]
2772 async fn a_mixed_baseline_is_not_flagged() {
2773 let dir = tempfile::tempdir().unwrap();
2774 let exec = WorktreeExecutor::new(dir.path());
2775 let contract = OutcomeContract {
2776 description: "d".into(),
2777 checks: vec![
2778 check("already_green", "exit 0"),
2779 check("must_fix", "exit 1"),
2780 ],
2781 };
2782
2783 let baseline = evaluate_contract_baseline(&contract, &exec).await;
2784 assert!(baseline[0].passed);
2785 assert!(
2786 !baseline[1].passed,
2787 "the red check is what gates the session"
2788 );
2789 assert!(
2790 !baseline_gates_nothing(&baseline),
2791 "one green check among red ones is information, not a fault"
2792 );
2793 }
2794
2795 #[tokio::test]
2796 async fn an_all_red_baseline_is_not_flagged() {
2797 let dir = tempfile::tempdir().unwrap();
2798 let exec = WorktreeExecutor::new(dir.path());
2799 let contract = OutcomeContract {
2800 description: "d".into(),
2801 checks: vec![check("must_fix", "exit 1")],
2802 };
2803 let baseline = evaluate_contract_baseline(&contract, &exec).await;
2804 assert!(!baseline_gates_nothing(&baseline));
2805 }
2806
2807 #[test]
2811 fn an_empty_baseline_is_not_all_green() {
2812 assert!(!baseline_gates_nothing(&[]));
2813 }
2814
2815 #[tokio::test]
2819 async fn baseline_agrees_with_the_narrated_evaluation() {
2820 let dir = tempfile::tempdir().unwrap();
2821 let exec = WorktreeExecutor::new(dir.path());
2822 let sink = EventSink::test_sink();
2823 let contract = OutcomeContract {
2824 description: "d".into(),
2825 checks: vec![check("green", "exit 0"), check("red", "exit 1")],
2826 };
2827
2828 let baseline = evaluate_contract_baseline(&contract, &exec).await;
2829 let narrated = evaluate_contract(&contract, &exec, &sink).await;
2830
2831 let verdicts = |rs: &[CheckResult]| -> Vec<(String, bool)> {
2832 rs.iter().map(|r| (r.name.clone(), r.passed)).collect()
2833 };
2834 assert_eq!(verdicts(&baseline), verdicts(&narrated));
2835 }
2836
2837 #[tokio::test]
2843 async fn a_dropped_constraint_is_repaired_into_the_contract() {
2844 use std::sync::Mutex;
2845 let script = Mutex::new(vec![
2850 r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2851 .to_string(),
2852 r#"{"missing":[1]}"#.to_string(),
2853 r#"{"description":"tests pass and the public signature is untouched",
2854 "checks":[{"name":"tests","command":"exit 0"},
2855 {"name":"signature_unchanged","command":"grep -q 'fn add(a: i32, b: i32)' src/lib.rs"}]}"#
2856 .to_string(),
2857 r#"{"missing":[]}"#.to_string(),
2858 ]);
2859 let prompts: Mutex<Vec<String>> = Mutex::new(Vec::new());
2860 let constraint = "The public signature of add() must stay exactly as it is.";
2861
2862 let contract = derive_contract(
2863 |r: ContractDraftRequest| {
2864 prompts.lock().unwrap().push(r.prompt);
2865 let next = script.lock().unwrap().remove(0);
2866 async move { Ok::<_, String>(next) }
2867 },
2868 "make the failing tests pass",
2869 "Top-level entries: src, Cargo.toml",
2870 3,
2871 &[constraint.to_string()],
2872 )
2873 .await
2874 .expect("the repair pass must produce a contract");
2875
2876 assert!(
2878 contract
2879 .checks
2880 .iter()
2881 .any(|c| c.name == "signature_unchanged"),
2882 "the dropped constraint must be repaired into the contract: {contract:?}"
2883 );
2884 let prompts = prompts.lock().unwrap();
2886 assert!(
2887 prompts[2].contains(constraint) && prompts[2].contains("DROPPED"),
2888 "the retry must name the dropped constraint verbatim: {}",
2889 prompts[2]
2890 );
2891 }
2892
2893 #[tokio::test]
2897 async fn an_unexpressible_constraint_is_disclosed_not_dropped() {
2898 use std::sync::Mutex;
2899 let script = Mutex::new(vec![
2900 r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2901 .to_string(),
2902 r#"{"missing":[1]}"#.to_string(),
2903 r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2904 .to_string(),
2905 r#"{"missing":[1]}"#.to_string(),
2906 ]);
2907 let constraint = "Get written sign-off from the CFO before merging.";
2908
2909 let contract = derive_contract(
2910 |_r: ContractDraftRequest| {
2911 let next = script.lock().unwrap().remove(0);
2912 async move { Ok::<_, String>(next) }
2913 },
2914 "make the failing tests pass",
2915 "Top-level entries: src",
2916 2,
2917 &[constraint.to_string()],
2918 )
2919 .await
2920 .expect("a valid draft beats no session, provided the gap is stated");
2921
2922 assert!(
2923 contract
2924 .description
2925 .contains("NOT VERIFIED BY THIS CONTRACT")
2926 && contract.description.contains(constraint),
2927 "an unexpressible constraint must be disclosed in the description: {}",
2928 contract.description
2929 );
2930 assert!(contract.checks.iter().any(|c| c.name == "tests"));
2932 }
2933
2934 #[tokio::test]
2943 async fn a_constraint_captured_only_in_prose_fires_the_disclosure() {
2944 use std::sync::Mutex;
2945 let script = Mutex::new(vec![
2946 r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
2948 .to_string(),
2949 r#"{"missing":[1],"prose_only":[]}"#.to_string(),
2950 r#"{"description":"tests pass, and the public signature of add() is unchanged",
2952 "checks":[{"name":"tests","command":"exit 0"}]}"#
2953 .to_string(),
2954 r#"{"missing":[],"prose_only":[1]}"#.to_string(),
2955 r#"{"description":"tests pass, and the public signature of add() is unchanged",
2957 "checks":[{"name":"tests","command":"exit 0"}]}"#
2958 .to_string(),
2959 r#"{"missing":[],"prose_only":[1]}"#.to_string(),
2960 ]);
2961 let prompts: Mutex<Vec<String>> = Mutex::new(Vec::new());
2962 let constraint = "The public signature of add() must stay exactly as it is.";
2963
2964 let contract = derive_contract(
2965 |r: ContractDraftRequest| {
2966 prompts.lock().unwrap().push(r.prompt);
2967 let next = script.lock().unwrap().remove(0);
2968 async move { Ok::<_, String>(next) }
2969 },
2970 "make the failing tests pass",
2971 "Top-level entries: src",
2972 3,
2973 &[constraint.to_string()],
2974 )
2975 .await
2976 .expect("a valid draft beats no session, provided the gap is stated");
2977
2978 assert!(
2979 contract
2980 .description
2981 .contains("NOT VERIFIED BY THIS CONTRACT")
2982 && contract.description.contains(constraint),
2983 "a prose-only constraint must be disclosed, not passed off as captured: {}",
2984 contract.description
2985 );
2986 assert!(
2987 contract.checks.iter().all(|c| c.name == "tests"),
2988 "nothing here gates the constraint: {contract:?}"
2989 );
2990 let prompts = prompts.lock().unwrap();
2994 assert!(
2995 prompts[4].contains(constraint) && prompts[4].contains("NOTHING VERIFIES IT"),
2996 "the repair must name the prose-only failure: {}",
2997 prompts[4]
2998 );
2999 }
3000
3001 #[tokio::test]
3005 async fn the_disclosed_draft_is_the_best_one_seen_not_the_newest() {
3006 use std::sync::Mutex;
3007 let script = Mutex::new(vec![
3008 r#"{"description":"d","checks":[{"name":"first_gated","command":"exit 0"}]}"#
3010 .to_string(),
3011 r#"{"missing":[2],"prose_only":[]}"#.to_string(),
3012 r#"{"description":"d","checks":[{"name":"gates_neither","command":"exit 0"}]}"#
3014 .to_string(),
3015 r#"{"missing":[1,2],"prose_only":[]}"#.to_string(),
3016 ]);
3017 let contract = derive_contract(
3018 |_r: ContractDraftRequest| {
3019 let next = script.lock().unwrap().remove(0);
3020 async move { Ok::<_, String>(next) }
3021 },
3022 "do the thing",
3023 "Top-level entries: src",
3024 2,
3025 &["constraint one".to_string(), "constraint two".to_string()],
3026 )
3027 .await
3028 .unwrap();
3029
3030 assert!(
3031 contract.checks.iter().any(|c| c.name == "first_gated"),
3032 "the better draft must survive: {contract:?}"
3033 );
3034 assert!(
3035 contract.description.contains("constraint two")
3036 && !contract.description.contains("constraint one"),
3037 "only the genuinely ungated constraint is disclosed: {}",
3038 contract.description
3039 );
3040 }
3041
3042 #[tokio::test]
3045 async fn a_failing_constraint_judge_does_not_block_derivation() {
3046 use std::sync::Mutex;
3047 let script = Mutex::new(vec![
3048 r#"{"description":"tests pass","checks":[{"name":"tests","command":"exit 0"}]}"#
3049 .to_string(),
3050 "the judge returned prose, not JSON".to_string(),
3051 ]);
3052 let contract = derive_contract(
3053 |_r: ContractDraftRequest| {
3054 let next = script.lock().unwrap().remove(0);
3055 async move { Ok::<_, String>(next) }
3056 },
3057 "make the failing tests pass",
3058 "Top-level entries: src",
3059 3,
3060 &["some constraint".to_string()],
3061 )
3062 .await
3063 .expect("an unusable judge must not fail the derivation");
3064 assert_eq!(contract.checks.len(), 1);
3065 }
3066}
3067
3068#[cfg(test)]
3069mod differential_tests {
3070 use super::*;
3071 use crate::coder::session::EventSink;
3072 use crate::coder::shell_tool::WorktreeExecutor;
3073
3074 fn check(name: &str, command: &str) -> ContractCheck {
3075 ContractCheck {
3076 name: name.into(),
3077 command: command.into(),
3078 expect_exit_zero: true,
3079 output_contains: None,
3080 timeout_secs: 10,
3081 baseline: false,
3082 differential: None,
3083 }
3084 }
3085
3086 fn capture(name: &str, output: &str, passed: bool) -> CheckResult {
3087 CheckResult {
3088 name: name.into(),
3089 passed,
3090 exit_code: Some(if passed { 0 } else { 1 }),
3091 output_tail: output.into(),
3092 duration_ms: 1,
3093 timed_out: false,
3094 deadline_clamped: false,
3095 }
3096 }
3097
3098 fn captures(name: &str, output: &str) -> BaselineCaptures {
3099 let mut m = BaselineCaptures::new();
3100 m.insert(name.into(), capture(name, output, true));
3101 m
3102 }
3103
3104 fn diff(baseline: &str, expect: DifferentialExpect) -> DifferentialCheck {
3105 DifferentialCheck {
3106 baseline: baseline.into(),
3107 expect,
3108 }
3109 }
3110
3111 #[test]
3115 fn a_contract_without_the_new_fields_still_parses() {
3116 let c: ContractCheck = serde_json::from_str(
3117 r#"{"name": "tests", "command": "cargo test", "timeout_secs": 600}"#,
3118 )
3119 .unwrap();
3120 assert!(!c.baseline);
3121 assert!(c.differential.is_none());
3122 let v = serde_json::to_value(&c).unwrap();
3124 assert!(v.get("baseline").is_none());
3125 assert!(v.get("differential").is_none());
3126 }
3127
3128 #[test]
3130 fn differential_kinds_round_trip_on_the_wire() {
3131 let json = r#"{
3132 "name": "rows_decreased",
3133 "command": "cat counter.txt",
3134 "differential": {
3135 "baseline": "orphan_rows",
3136 "expect": { "delta_within": { "max": -100.0 } }
3137 }
3138 }"#;
3139 let c: ContractCheck = serde_json::from_str(json).unwrap();
3140 match &c.differential.as_ref().unwrap().expect {
3142 DifferentialExpect::DeltaWithin { min, max } => {
3143 assert_eq!(*min, None);
3144 assert_eq!(*max, Some(-100.0));
3145 }
3146 DifferentialExpect::Changed | DifferentialExpect::Unchanged => {
3147 panic!("parsed the wrong kind")
3148 }
3149 }
3150 for (wire, expect) in [
3151 ("\"changed\"", DifferentialExpect::Changed),
3152 ("\"unchanged\"", DifferentialExpect::Unchanged),
3153 ] {
3154 let parsed: DifferentialExpect = serde_json::from_str(wire).unwrap();
3155 assert_eq!(parsed, expect);
3156 }
3157 }
3158
3159 fn contract(checks: Vec<ContractCheck>) -> OutcomeContract {
3162 OutcomeContract {
3163 description: "d".into(),
3164 checks,
3165 }
3166 }
3167
3168 #[test]
3169 fn a_baseline_capture_needs_no_assertion_but_a_normal_check_still_does() {
3170 let mut cap = check("before", "cat counter.txt");
3171 cap.baseline = true;
3172 cap.expect_exit_zero = false;
3173 let mut gate = check("after", "cat counter.txt");
3174 gate.differential = Some(diff("before", DifferentialExpect::Changed));
3175 assert!(contract(vec![cap, gate]).validate().is_empty());
3176
3177 let mut bare = check("nothing", "true");
3178 bare.expect_exit_zero = false;
3179 let issues = contract(vec![bare]).validate();
3180 assert!(issues.iter().any(|i| i.contains("asserts nothing")));
3181 }
3182
3183 #[test]
3184 fn validation_rejects_the_malformed_differential_shapes() {
3185 let mut both = check("x", "true");
3187 both.baseline = true;
3188 both.differential = Some(diff("x", DifferentialExpect::Changed));
3189 let issues = contract(vec![both, check("y", "true")]).validate();
3190 assert!(
3191 issues
3192 .iter()
3193 .any(|i| i.contains("both a baseline capture and a differential")),
3194 "{issues:?}"
3195 );
3196
3197 let mut orphan = check("after", "true");
3199 orphan.differential = Some(diff("nowhere", DifferentialExpect::Changed));
3200 let issues = contract(vec![orphan]).validate();
3201 assert!(
3202 issues
3203 .iter()
3204 .any(|i| i.contains("no check by that name is marked baseline")),
3205 "{issues:?}"
3206 );
3207
3208 let mut early = check("after", "true");
3210 early.differential = Some(diff("before", DifferentialExpect::Changed));
3211 let mut late_cap = check("before", "true");
3212 late_cap.baseline = true;
3213 let issues = contract(vec![early, late_cap]).validate();
3214 assert!(
3215 issues.iter().any(|i| i.contains("declared after it")),
3216 "{issues:?}"
3217 );
3218
3219 let mut cap = check("before", "true");
3221 cap.baseline = true;
3222 let mut unbounded = check("after", "true");
3223 unbounded.differential = Some(diff(
3224 "before",
3225 DifferentialExpect::DeltaWithin {
3226 min: None,
3227 max: None,
3228 },
3229 ));
3230 let issues = contract(vec![cap.clone(), unbounded]).validate();
3231 assert!(issues.iter().any(|i| i.contains("no bounds")), "{issues:?}");
3232
3233 let mut inverted = check("after", "true");
3235 inverted.differential = Some(diff(
3236 "before",
3237 DifferentialExpect::DeltaWithin {
3238 min: Some(5.0),
3239 max: Some(1.0),
3240 },
3241 ));
3242 let issues = contract(vec![cap.clone(), inverted]).validate();
3243 assert!(
3244 issues.iter().any(|i| i.contains("min above max")),
3245 "{issues:?}"
3246 );
3247
3248 let issues = contract(vec![cap]).validate();
3250 assert!(
3251 issues
3252 .iter()
3253 .any(|i| i.contains("every check is a baseline capture")),
3254 "{issues:?}"
3255 );
3256 }
3257
3258 #[test]
3261 fn changed_passes_on_a_move_and_fails_identical_with_the_message() {
3262 let d = diff("hb", DifferentialExpect::Changed);
3263 let caps = captures("hb", "ERROR");
3264 assert!(evaluate_differential(&d, &caps, "HEALTHY").is_ok());
3265 let err = evaluate_differential(&d, &caps, "ERROR").unwrap_err();
3266 assert!(
3267 err.contains("expected the output to CHANGE from baseline 'hb'"),
3268 "{err}"
3269 );
3270 assert!(err.contains("identical to the captured value"), "{err}");
3271 }
3272
3273 #[test]
3274 fn unchanged_holds_the_control_group_and_names_the_violation() {
3275 let d = diff("control", DifferentialExpect::Unchanged);
3276 let caps = captures("control", "rows=42");
3277 assert!(evaluate_differential(&d, &caps, "rows=42\n").is_ok());
3278 let err = evaluate_differential(&d, &caps, "rows=41").unwrap_err();
3279 assert!(err.contains("UNCHANGED from baseline 'control'"), "{err}");
3280 assert!(err.contains("control-group"), "{err}");
3281 assert!(
3282 err.contains("\"rows=42\"") && err.contains("\"rows=41\""),
3283 "{err}"
3284 );
3285 }
3286
3287 #[test]
3288 fn delta_within_bounds_both_sides_and_reports_the_numbers() {
3289 let caps = captures("orphans", "orphaned rows: 435,594");
3290 let d = diff(
3292 "orphans",
3293 DifferentialExpect::DeltaWithin {
3294 min: None,
3295 max: Some(-100.0),
3296 },
3297 );
3298 assert!(evaluate_differential(&d, &caps, "orphaned rows: 76,330").is_ok());
3299 let err = evaluate_differential(&d, &caps, "orphaned rows: 435,600").unwrap_err();
3300 assert!(err.contains("delta 6"), "{err}");
3301 assert!(err.contains("435594 -> 435600"), "{err}");
3302 assert!(
3303 err.contains("outside the allowed bounds [-inf, -100]"),
3304 "{err}"
3305 );
3306
3307 let up = diff(
3309 "orphans",
3310 DifferentialExpect::DeltaWithin {
3311 min: Some(5.0),
3312 max: None,
3313 },
3314 );
3315 assert!(evaluate_differential(&up, &caps, "435600").is_ok());
3316 let err = evaluate_differential(&up, &caps, "435595").unwrap_err();
3317 assert!(
3318 err.contains("outside the allowed bounds [5, +inf]"),
3319 "{err}"
3320 );
3321 }
3322
3323 #[test]
3324 fn delta_within_names_which_side_was_not_numeric() {
3325 let d = diff(
3326 "n",
3327 DifferentialExpect::DeltaWithin {
3328 min: None,
3329 max: Some(0.0),
3330 },
3331 );
3332 let err = evaluate_differential(&d, &captures("n", "no digits here"), "7").unwrap_err();
3333 assert!(
3334 err.contains("baseline 'n' captured no numeric value"),
3335 "{err}"
3336 );
3337 let err = evaluate_differential(&d, &captures("n", "7"), "no digits here").unwrap_err();
3338 assert!(
3339 err.contains("the check output carries no numeric value"),
3340 "{err}"
3341 );
3342 }
3343
3344 #[test]
3345 fn a_missing_or_failed_capture_fails_closed_with_the_reason() {
3346 let d = diff("gone", DifferentialExpect::Changed);
3347 let err = evaluate_differential(&d, &BaselineCaptures::new(), "x").unwrap_err();
3348 assert!(err.contains("baseline 'gone' was never captured"), "{err}");
3349
3350 let mut caps = BaselineCaptures::new();
3351 caps.insert("gone".into(), capture("gone", "x", false));
3352 let err = evaluate_differential(&d, &caps, "y").unwrap_err();
3353 assert!(err.contains("failed at capture time"), "{err}");
3354 }
3355
3356 #[test]
3357 fn first_number_reads_counters_out_of_prose() {
3358 assert_eq!(first_number("orphaned rows: 435,594"), Some(435_594.0));
3359 assert_eq!(first_number("-12.5 degrees"), Some(-12.5));
3360 assert_eq!(first_number("count=76330"), Some(76_330.0));
3361 assert_eq!(first_number("no digits"), None);
3362 assert_eq!(first_number(""), None);
3363 }
3364
3365 #[tokio::test]
3373 async fn a_counter_decrease_is_expressible_and_enforced_end_to_end() {
3374 let dir = tempfile::tempdir().unwrap();
3375 std::fs::write(dir.path().join("counter.txt"), "435594\n").unwrap();
3376 let exec = WorktreeExecutor::new(dir.path());
3377 let sink = EventSink::test_sink();
3378
3379 let mut cap = check("orphan_rows", "cat counter.txt");
3380 cap.baseline = true;
3381 let mut gate = check("orphan_rows_decreased", "cat counter.txt");
3382 gate.differential = Some(diff(
3383 "orphan_rows",
3384 DifferentialExpect::DeltaWithin {
3385 min: None,
3386 max: Some(-100.0),
3387 },
3388 ));
3389 let contract = contract(vec![cap, gate]);
3390 assert!(contract.validate().is_empty());
3391
3392 let baseline = evaluate_contract_baseline(&contract, &exec).await;
3396 assert!(baseline[0].passed, "the capture itself succeeds");
3397 assert!(
3398 !baseline[1].passed,
3399 "nothing has changed yet, so the differential must be red at baseline"
3400 );
3401 assert!(!baseline_gates_nothing(&baseline));
3402 let caps = collect_baseline_captures(&contract, &baseline);
3403 assert_eq!(caps.len(), 1);
3404 assert!(caps["orphan_rows"].output_tail.contains("435594"));
3405
3406 std::fs::write(dir.path().join("counter.txt"), "76330\n").unwrap();
3408
3409 let results =
3411 evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3412 assert_eq!(results.len(), 2, "both executions are present");
3413 assert!(
3414 results[0].output_tail.contains("435594"),
3415 "the capture result is the session-start one, not a re-run: {}",
3416 results[0].output_tail
3417 );
3418 assert!(results[1].passed, "435594 -> 76330 is a delta of -359264");
3419 assert!(results.iter().all(|r| r.passed));
3420
3421 std::fs::write(dir.path().join("counter.txt"), "500000\n").unwrap();
3423 let results =
3424 evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3425 assert!(!results[1].passed);
3426 assert!(
3427 results[1]
3428 .output_tail
3429 .contains("outside the allowed bounds"),
3430 "{}",
3431 results[1].output_tail
3432 );
3433 }
3434
3435 #[tokio::test]
3438 async fn a_control_group_unchanged_claim_is_expressible_and_enforced() {
3439 let dir = tempfile::tempdir().unwrap();
3440 std::fs::write(dir.path().join("control.txt"), "tenant-b rows: 42\n").unwrap();
3441 let exec = WorktreeExecutor::new(dir.path());
3442 let sink = EventSink::test_sink();
3443
3444 let mut cap = check("control_before", "cat control.txt");
3445 cap.baseline = true;
3446 let mut gate = check("control_unmoved", "cat control.txt");
3447 gate.differential = Some(diff("control_before", DifferentialExpect::Unchanged));
3448 let contract = contract(vec![cap, gate]);
3449 assert!(contract.validate().is_empty());
3450
3451 let baseline = evaluate_contract_baseline(&contract, &exec).await;
3452 assert!(baseline.iter().all(|r| r.passed));
3455 let caps = collect_baseline_captures(&contract, &baseline);
3456
3457 let results =
3459 evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3460 assert!(results.iter().all(|r| r.passed));
3461
3462 std::fs::write(dir.path().join("control.txt"), "tenant-b rows: 41\n").unwrap();
3464 let results =
3465 evaluate_contract_within_baselines(&contract, &exec, &sink, None, &caps).await;
3466 assert!(!results[1].passed);
3467 assert!(
3468 results[1].output_tail.contains("control-group"),
3469 "{}",
3470 results[1].output_tail
3471 );
3472 }
3473
3474 #[tokio::test]
3477 async fn without_captures_a_differential_check_fails_closed() {
3478 let dir = tempfile::tempdir().unwrap();
3479 std::fs::write(dir.path().join("counter.txt"), "1\n").unwrap();
3480 let exec = WorktreeExecutor::new(dir.path());
3481 let sink = EventSink::test_sink();
3482
3483 let mut cap = check("before", "cat counter.txt");
3484 cap.baseline = true;
3485 let mut gate = check("after", "cat counter.txt");
3486 gate.differential = Some(diff("before", DifferentialExpect::Changed));
3487 let contract = contract(vec![cap, gate]);
3488
3489 let results = evaluate_contract(&contract, &exec, &sink).await;
3490 assert!(
3491 !results[0].passed && results[0].output_tail.contains("never captured"),
3492 "{}",
3493 results[0].output_tail
3494 );
3495 assert!(
3496 !results[1].passed && results[1].output_tail.contains("never captured"),
3497 "{}",
3498 results[1].output_tail
3499 );
3500 }
3501
3502 #[test]
3505 fn render_states_captures_and_differentials() {
3506 let mut cap = check("orphan_rows", "cat counter.txt");
3507 cap.baseline = true;
3508 let mut gate = check("decreased", "cat counter.txt");
3509 gate.differential = Some(diff(
3510 "orphan_rows",
3511 DifferentialExpect::DeltaWithin {
3512 min: None,
3513 max: Some(-100.0),
3514 },
3515 ));
3516 let rendered = contract(vec![cap, gate]).render();
3517 assert!(
3518 rendered.contains("baseline capture at session start"),
3519 "{rendered}"
3520 );
3521 assert!(
3522 rendered.contains("vs baseline 'orphan_rows': delta within [-inf, -100]"),
3523 "{rendered}"
3524 );
3525 }
3526}