1use std::path::{Path, PathBuf};
13use std::time::Instant;
14
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use speccoder_core::strength::{
18 self as rules, AcceptanceStrength, Gate, Mutants, NearMiss, Outcome, Permitted,
19 PermittedOutcome, Ruling, Set, State,
20};
21use speccoder_core::verify::{CheckResult, CheckStatus, RedClass, RunnerSummary, classify_red};
22
23use crate::held_out::HeldOutFile;
24use crate::integrity::{JudgeSnapshot, sha256_hex};
25use crate::substrate::run_config::StrengthConfig;
26use crate::tools::policy::Policy;
27use crate::{mutation, sandbox, worktree};
28
29#[must_use]
31pub fn dir(plane_dir: &Path, feature: &str, task: &str) -> PathBuf {
32 plane_dir.join("strength").join(feature).join(task)
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Variant {
38 pub kind: String,
40 pub scenario: String,
42 pub rationale: String,
44 pub files: Vec<(String, String)>,
46}
47
48impl Variant {
49 #[must_use]
51 pub fn digest(&self) -> String {
52 let mut text = String::new();
53 for (p, c) in &self.files {
54 text.push_str(&format!("{p}\n{}\n{c}\n", c.len()));
55 }
56 format!("sha256:{}", sha256_hex(text.as_bytes()))
57 }
58}
59
60#[must_use]
64pub fn validate(
65 answer: &Value,
66 changed: &[(String, String)],
67 config: &StrengthConfig,
68 notes: &mut Vec<String>,
69) -> (Vec<Variant>, Vec<Variant>) {
70 let mut wrong = Vec::new();
71 let mut permitted = Vec::new();
72 let mut seen = std::collections::BTreeSet::new();
73 for (i, v) in answer["variants"]
74 .as_array()
75 .into_iter()
76 .flatten()
77 .enumerate()
78 {
79 let kind = v["kind"].as_str().unwrap_or_default().to_owned();
80 let mut files = Vec::new();
81 let mut bad = None;
82 for f in v["files"].as_array().into_iter().flatten() {
83 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
84 bad = Some("a file without path or content".to_owned());
85 break;
86 };
87 let path = match crate::tools::paths::normalise(path) {
88 Ok(p) => p,
89 Err(e) => {
90 bad = Some(format!("`{path}`: {}", e.0));
91 break;
92 }
93 };
94 match changed.iter().find(|(p, _)| *p == path) {
95 None => {
96 bad = Some(format!("`{path}` is not a changed source file"));
97 break;
98 }
99 Some((_, now)) if now == content => {}
100 Some(_) => files.push((path, content.to_owned())),
101 }
102 }
103 if bad.is_none() && files.is_empty() {
104 bad = Some("identical to the change".to_owned());
105 }
106 let variant = Variant {
107 kind: kind.clone(),
108 scenario: v["scenario"].as_str().unwrap_or_default().to_owned(),
109 rationale: v["rationale"].as_str().unwrap_or_default().to_owned(),
110 files,
111 };
112 if bad.is_none() && !seen.insert(variant.digest()) {
113 bad = Some("the same as an earlier variant".to_owned());
114 }
115 if let Some(why) = bad {
116 notes.push(format!("variant {} dropped: {why}", i + 1));
117 continue;
118 }
119 match kind.as_str() {
120 "wrong" if wrong.len() < config.near_miss as usize => wrong.push(variant),
121 "permitted" if permitted.len() < config.permitted as usize => permitted.push(variant),
122 _ => notes.push(format!(
123 "variant {} dropped: more {kind} variants than asked for",
124 i + 1
125 )),
126 }
127 }
128 (wrong, permitted)
129}
130
131#[must_use]
133pub fn classify(result: &CheckResult, summary: Option<&RunnerSummary>) -> Outcome {
134 if result.status == CheckStatus::Passed {
135 return Outcome::Survived;
136 }
137 match classify_red(result, summary) {
138 RedClass::Assertion | RedClass::Absent => Outcome::Rejected,
139 RedClass::Timeout => Outcome::Timeout,
140 _ => Outcome::Unviable,
143 }
144}
145
146#[derive(Debug, Clone)]
149struct Ran {
150 visible: Outcome,
151 combined: Outcome,
152 failing: Option<String>,
153}
154
155fn tail(r: &CheckResult) -> String {
156 let text = if r.stdout_tail.trim().is_empty() {
157 &r.stderr_tail
158 } else {
159 &r.stdout_tail
160 };
161 let lines: Vec<&str> = text.lines().collect();
162 lines[lines.len().saturating_sub(12)..].join("\n")
163}
164
165pub struct Setup<'a> {
167 pub wt_root: &'a Path,
169 pub project: &'a Path,
171 pub tmp: &'a Path,
173 pub runner: &'a sandbox::Runner,
175 pub policy: &'a Policy,
177 pub acceptance: &'a [Vec<String>],
180 pub held_out: &'a [(HeldOutFile, String)],
182 pub mutation: Option<&'a mutation::Config>,
184 pub patch: &'a str,
186 pub config: &'a StrengthConfig,
188 pub keep: &'a Path,
190 pub rulings: &'a [Ruling],
192}
193
194fn apply(root: &Path, files: &[(String, String)]) -> Result<(), String> {
196 for (p, c) in files {
197 std::fs::write(root.join(p), c).map_err(|e| format!("{p}: {e}"))?;
198 }
199 Ok(())
200}
201
202fn judge_tree(s: &Setup<'_>, assertion_only: bool) -> Result<Ran, String> {
204 let rank = |o: Outcome| match o {
206 Outcome::Rejected => 3,
207 Outcome::Timeout => 2,
208 Outcome::Unviable => 1,
209 Outcome::Survived => 0,
210 };
211 let mut visible = Outcome::Survived;
212 let mut failing = None;
213 for argv in s.acceptance {
214 let (r, summary) = crate::pipeline::acceptance_run(s.project, argv, s.runner);
215 let o = classify(&r, summary.as_ref());
216 if rank(o) > rank(visible) {
217 visible = o;
218 failing = Some(tail(&r));
219 }
220 }
221 let mut combined = visible;
222 if visible == Outcome::Survived && !s.held_out.is_empty() {
224 let runs = crate::held_out::overlay_run(
225 s.wt_root, s.project, s.tmp, s.held_out, s.runner, s.policy,
226 )?;
227 for ran in runs.iter().filter_map(|r| r.result.as_ref()) {
228 let o = classify(&ran.0, ran.1.as_ref());
229 if o == Outcome::Rejected || (o == Outcome::Timeout && !assertion_only) {
230 combined = o;
231 failing = Some(tail(&ran.0));
232 break;
233 }
234 }
235 }
236 Ok(Ran {
237 visible,
238 combined,
239 failing,
240 })
241}
242
243fn variant_patch(changed: &[(String, String)], v: &Variant) -> String {
245 let mut out = format!("# {} — {}: {}\n", v.kind, v.scenario, v.rationale);
246 for (path, content) in &v.files {
247 let now = changed
248 .iter()
249 .find(|(p, _)| p == path)
250 .map(|(_, c)| c.as_str())
251 .unwrap_or_default();
252 out.push_str(
253 &similar::TextDiff::from_lines(now, content)
254 .unified_diff()
255 .header(&format!("a/{path}"), &format!("b/{path}"))
256 .to_string(),
257 );
258 }
259 out
260}
261
262#[must_use]
264pub fn changed_files(wt_root: &Path, patch: &str) -> Vec<(String, String)> {
265 mutation::changed_sources(patch)
266 .into_iter()
267 .filter_map(|p| {
268 std::fs::read_to_string(wt_root.join(&p))
269 .ok()
270 .map(|c| (p, c))
271 })
272 .collect()
273}
274
275pub struct Authored {
277 pub answer: Result<Value, String>,
279 pub run_id: Option<String>,
281 pub usage: Option<Value>,
283}
284
285pub fn measure(
290 s: &Setup<'_>,
291 author: impl FnOnce(&[(String, String)]) -> Authored,
292) -> Result<AcceptanceStrength, String> {
293 let began = Instant::now();
294 let deadline = began + s.config.timeout;
295 let changed = changed_files(s.wt_root, s.patch);
296 let held = !s.held_out.is_empty();
297 let mut notes = Vec::new();
298 let done =
299 |visible, combined, mutants, near_misses, permitted, rulings, notes, run_id, usage| {
300 AcceptanceStrength {
301 visible,
302 combined,
303 held_out: held,
304 mutants,
305 near_misses,
306 permitted,
307 rulings,
308 gate: Gate::Off,
309 notes,
310 run_id,
311 usage,
312 duration_ms: u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX),
313 }
314 };
315 if changed.is_empty() {
316 return Ok(done(
317 rules::unmeasured(State::NotApplicable),
318 rules::unmeasured(State::NotApplicable),
319 None,
320 Vec::new(),
321 Vec::new(),
322 Vec::new(),
323 vec!["no source change".to_owned()],
324 None,
325 None,
326 ));
327 }
328 let base = worktree::checkpoint(s.wt_root, s.tmp).map_err(|e| e.0)?;
329 let judged = JudgeSnapshot::take(s.wt_root, s.policy);
330 let mut partial = false;
331
332 let mutants = match s.mutation {
334 None => {
335 notes.push("mutants not measured: no [mutation] tool".to_owned());
336 None
337 }
338 Some(m) => {
339 let mut m = m.clone();
340 m.timeout = m
341 .timeout
342 .min(deadline.saturating_duration_since(Instant::now()));
343 let scratch = s.runner.scratch().unwrap_or_else(|| s.tmp.to_path_buf());
344 let first = s.acceptance.first().map(Vec::as_slice).unwrap_or_default();
345 match mutation::run_against(&m, s.project, s.patch, &scratch, s.runner, first) {
346 Some(r) if r.get("error").is_none() => {
347 let n = |k: &str| u32::try_from(r[k].as_u64().unwrap_or(0)).unwrap_or(u32::MAX);
348 Some(Mutants {
349 tool: r["tool"].as_str().unwrap_or_default().to_owned(),
350 caught: n("caught"),
351 missed: n("missed"),
352 timeout: n("timeout"),
353 unviable: n("unviable"),
354 survivors: r["survivors"]
355 .as_array()
356 .into_iter()
357 .flatten()
358 .filter_map(|v| v.as_str().map(str::to_owned))
359 .collect(),
360 })
361 }
362 Some(r) => {
363 notes.push(format!(
364 "mutants not measured: {}",
365 r["error"].as_str().unwrap_or("the tool failed")
366 ));
367 None
368 }
369 None => None,
370 }
371 }
372 };
373
374 let (mut run_id, mut usage) = (None, None);
376 let (wrong, allowed) = if s.config.near_miss + s.config.permitted == 0 {
377 (Vec::new(), Vec::new())
378 } else if Instant::now() >= deadline {
379 partial = true;
380 notes.push("near misses not measured: the strength budget ran out".to_owned());
381 (Vec::new(), Vec::new())
382 } else {
383 let a = author(&changed);
384 run_id = a.run_id;
385 usage = a.usage;
386 match a.answer {
387 Ok(answer) => validate(&answer, &changed, s.config, &mut notes),
388 Err(e) => {
389 notes.push(format!("near misses not measured: {e}"));
390 (Vec::new(), Vec::new())
391 }
392 }
393 };
394 let _ = std::fs::create_dir_all(s.keep);
395
396 let mut run_one = |v: &Variant, assertion_only: bool| -> Result<Option<Ran>, String> {
398 if Instant::now() >= deadline {
399 partial = true;
400 return Ok(None);
401 }
402 let ran = apply(s.wt_root, &v.files).and_then(|()| judge_tree(s, assertion_only));
403 worktree::restore(s.wt_root, s.tmp, &base).map_err(|e| e.0)?;
404 ran.map(Some)
405 };
406 let mut near_misses = Vec::new();
407 for (i, v) in wrong.iter().enumerate() {
408 let Some(ran) = run_one(v, false)? else { break };
409 near_misses.push(NearMiss {
410 id: format!("near-miss-{}", i + 1),
411 scenario: v.scenario.clone(),
412 rationale: v.rationale.clone(),
413 visible: ran.visible,
414 combined: ran.combined,
415 });
416 if ran.combined == Outcome::Survived {
417 let _ = std::fs::write(
418 s.keep.join(format!("near-miss-{}.patch", i + 1)),
419 variant_patch(&changed, v),
420 );
421 }
422 }
423 let mut permitted = Vec::new();
424 let mut rulings = Vec::new();
425 for (i, v) in allowed.iter().enumerate() {
426 let Some(ran) = run_one(v, true)? else { break };
427 let digest = v.digest();
428 let id = format!("permitted-{}", i + 1);
429 let mut outcome = match ran.combined {
430 Outcome::Survived => PermittedOutcome::Accepted,
431 Outcome::Rejected => PermittedOutcome::Rejected,
432 Outcome::Timeout => PermittedOutcome::Timeout,
433 Outcome::Unviable => PermittedOutcome::Unviable,
434 };
435 let mut patch = None;
436 if outcome == PermittedOutcome::Rejected {
437 if let Some(r) = s.rulings.iter().find(|r| r.digest == digest) {
438 outcome = PermittedOutcome::Ruled;
439 rulings.push(r.clone());
440 } else {
441 let path = s.keep.join(format!("{id}.patch"));
442 let _ = std::fs::write(&path, variant_patch(&changed, v));
443 patch = Some(path.to_string_lossy().into_owned());
444 }
445 }
446 permitted.push(Permitted {
447 id,
448 scenario: v.scenario.clone(),
449 rationale: v.rationale.clone(),
450 digest,
451 outcome,
452 failing: matches!(
453 outcome,
454 PermittedOutcome::Rejected | PermittedOutcome::Ruled
455 )
456 .then_some(ran.failing)
457 .flatten(),
458 patch,
459 });
460 }
461 if partial {
462 notes.push("the strength budget ran out: partial".to_owned());
463 }
464
465 let after = worktree::checkpoint(s.wt_root, s.tmp).map_err(|e| e.0)?;
467 if after != base {
468 return Err("measuring acceptance strength left changes in the worktree".to_owned());
469 }
470 if let Some(d) = judged
471 .drift(
472 &JudgeSnapshot::take(s.wt_root, s.policy),
473 s.policy,
474 &std::collections::BTreeSet::new(),
475 )
476 .first()
477 {
478 return Err(format!(
479 "measuring acceptance strength changed judge input {}",
480 d.path
481 ));
482 }
483 let visible = rules::strength(mutants.as_ref(), &near_misses, Set::Visible, partial);
484 let combined = rules::strength(mutants.as_ref(), &near_misses, Set::Combined, partial);
485 Ok(done(
486 visible,
487 combined,
488 mutants,
489 near_misses,
490 permitted,
491 rulings,
492 notes,
493 run_id,
494 usage,
495 ))
496}
497
498#[must_use]
501pub fn weak(a: &AcceptanceStrength) -> Value {
502 let mut out: Vec<Value> = a
503 .near_misses
504 .iter()
505 .filter(|n| n.combined == Outcome::Survived)
506 .map(|n| json!({ "source": "near_miss", "scenario": n.scenario, "wrong": n.rationale }))
507 .collect();
508 if let Some(m) = &a.mutants {
509 out.extend(
510 m.survivors
511 .iter()
512 .take(12)
513 .map(|s| json!({ "source": "mutant", "wrong": s })),
514 );
515 }
516 json!(out)
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct Rulings {
522 pub rulings: Vec<Ruling>,
524}
525
526const RULINGS: &str = "rulings.json";
527const HELD: &str = "held.json";
528
529#[must_use]
531pub fn rulings(keep: &Path) -> Vec<Ruling> {
532 std::fs::read_to_string(keep.join(RULINGS))
533 .ok()
534 .and_then(|t| serde_json::from_str::<Rulings>(&t).ok())
535 .map(|r| r.rulings)
536 .unwrap_or_default()
537}
538
539pub fn add_ruling(keep: &Path, ruling: Ruling) -> std::io::Result<()> {
541 let mut all = rulings(keep);
542 all.retain(|r| r.digest != ruling.digest);
543 all.push(ruling);
544 std::fs::create_dir_all(keep)?;
545 std::fs::write(
546 keep.join(RULINGS),
547 serde_json::to_vec_pretty(&Rulings { rulings: all }).map_err(std::io::Error::other)?,
548 )
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
553pub struct Held {
554 pub findings: Vec<Permitted>,
556}
557
558pub fn hold(keep: &Path, findings: &[&Permitted]) -> std::io::Result<()> {
560 std::fs::create_dir_all(keep)?;
561 std::fs::write(
562 keep.join(HELD),
563 serde_json::to_vec_pretty(&Held {
564 findings: findings.iter().map(|p| (*p).clone()).collect(),
565 })
566 .map_err(std::io::Error::other)?,
567 )
568}
569
570#[must_use]
572pub fn held(keep: &Path) -> Option<Held> {
573 serde_json::from_str(&std::fs::read_to_string(keep.join(HELD)).ok()?).ok()
574}
575
576pub fn release(keep: &Path) {
578 let _ = std::fs::remove_file(keep.join(HELD));
579}
580
581#[must_use]
584pub fn held_elsewhere(plane_dir: &Path, feature: &str, task: &str) -> Option<String> {
585 std::fs::read_dir(plane_dir.join("strength").join(feature))
586 .ok()?
587 .flatten()
588 .map(|e| e.file_name().to_string_lossy().into_owned())
589 .filter(|t| t != task)
590 .find(|t| held(&dir(plane_dir, feature, t)).is_some())
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 fn changed() -> Vec<(String, String)> {
598 vec![(
599 "src/lib.rs".to_owned(),
600 "pub fn add(a: i32, b: i32) -> i32 { a + b }\n".to_owned(),
601 )]
602 }
603
604 #[test]
605 fn only_real_changes_of_changed_files_are_kept() {
606 let answer = json!({ "variants": [
607 { "kind": "wrong", "scenario": "US1-1", "rationale": "off by one", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { a + b + 1 }\n" }] },
608 { "kind": "wrong", "scenario": "US1-1", "rationale": "same again", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { a + b + 1 }\n" }] },
609 { "kind": "wrong", "scenario": "US1-2", "rationale": "elsewhere", "files": [{ "path": "src/other.rs", "content": "x" }] },
610 { "kind": "wrong", "scenario": "US1-2", "rationale": "no change", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { a + b }\n" }] },
611 { "kind": "permitted", "scenario": "US1-3", "rationale": "commutes", "files": [{ "path": "./src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { b + a }\n" }] },
612 { "kind": "permitted", "scenario": "US1-3", "rationale": "too many", "files": [{ "path": "src/lib.rs", "content": "pub fn add(a: i32, b: i32) -> i32 { b.wrapping_add(a) }\n" }] },
613 ], "notes": [] });
614 let mut notes = Vec::new();
615 let (wrong, permitted) =
616 validate(&answer, &changed(), &StrengthConfig::default(), &mut notes);
617 assert_eq!(wrong.len(), 1);
618 assert_eq!(permitted.len(), 1);
619 assert_eq!(permitted[0].files[0].0, "src/lib.rs");
620 let text = notes.join("\n");
621 for why in [
622 "same as an earlier",
623 "not a changed source file",
624 "identical to the change",
625 "more permitted",
626 ] {
627 assert!(text.contains(why), "{why}: {text}");
628 }
629 assert!(wrong[0].digest().starts_with("sha256:"));
630 assert_ne!(wrong[0].digest(), permitted[0].digest());
631 }
632
633 #[test]
634 fn a_pass_survives_an_assertion_rejects_a_build_error_is_unviable() {
635 let result = |status, stdout: &str| CheckResult {
636 name: "acceptance".to_owned(),
637 argv: vec!["cargo".to_owned(), "test".to_owned()],
638 status,
639 exit_code: Some(i32::from(status != CheckStatus::Passed)),
640 duration_ms: 1,
641 junit: false,
642 tests: None,
643 stdout_tail: stdout.to_owned(),
644 stderr_tail: String::new(),
645 truncated: false,
646 note: None,
647 flaky: false,
648 };
649 assert_eq!(
650 classify(&result(CheckStatus::Passed, ""), None),
651 Outcome::Survived
652 );
653 assert_eq!(
654 classify(&result(CheckStatus::TimedOut, ""), None),
655 Outcome::Timeout
656 );
657 let failed = RunnerSummary {
658 passed: 1,
659 failed: 1,
660 errors: 0,
661 build_failed: false,
662 unresolved_only: false,
663 };
664 assert_eq!(
665 classify(
666 &result(CheckStatus::Failed, "test result: FAILED"),
667 Some(&failed)
668 ),
669 Outcome::Rejected
670 );
671 assert_eq!(
672 classify(
673 &result(
674 CheckStatus::Failed,
675 "error[E0308]: mismatched types\nerror: could not compile `x`"
676 ),
677 None
678 ),
679 Outcome::Unviable
680 );
681 }
682
683 #[test]
684 fn rulings_and_holds_are_kept_per_task() {
685 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
686 let keep = dir(state.path(), "001-x", "T005");
687 let ruling = Ruling {
688 digest: "sha256:ab".to_owned(),
689 scenario: "US2-1".to_owned(),
690 by: "Dana".to_owned(),
691 reason: "the plan fixes the order".to_owned(),
692 };
693 add_ruling(&keep, ruling.clone()).unwrap_or_else(|e| panic!("{e}"));
694 add_ruling(&keep, ruling.clone()).unwrap_or_else(|e| panic!("{e}"));
695 assert_eq!(rulings(&keep), [ruling]);
696 let p = Permitted {
697 id: "permitted-1".to_owned(),
698 scenario: "US2-1".to_owned(),
699 rationale: "another order".to_owned(),
700 digest: "sha256:ab".to_owned(),
701 outcome: PermittedOutcome::Rejected,
702 failing: None,
703 patch: None,
704 };
705 hold(&keep, &[&p]).unwrap_or_else(|e| panic!("{e}"));
706 assert_eq!(
707 held_elsewhere(state.path(), "001-x", "T006").as_deref(),
708 Some("T005")
709 );
710 assert!(held_elsewhere(state.path(), "001-x", "T005").is_none());
711 release(&keep);
712 assert!(held(&keep).is_none());
713 }
714}