1use serde::{Deserialize, Serialize};
13
14use crate::actions::Via;
15use crate::next::NextStep;
16use crate::verify::{HeldOut, RedClass, Verdict};
17
18pub const DEFAULT_TRIES: u32 = 3;
20pub const MAX_TRIES: u32 = 5;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25pub struct Statement {
26 pub id: String,
29 pub text: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35pub struct Triad {
36 pub current: Statement,
38 pub expected: Vec<Statement>,
40 pub unchanged: Vec<Statement>,
42 pub digest: String,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum TriadError {
50 Empty,
52 NoExpected,
54}
55
56impl TriadError {
57 #[must_use]
59 pub const fn code(self) -> &'static str {
60 match self {
61 Self::Empty => "BUG-EMPTY",
62 Self::NoExpected => "BUG-NO-EXPECTED",
63 }
64 }
65
66 #[must_use]
68 pub const fn message(self) -> &'static str {
69 match self {
70 Self::Empty => {
71 "the symptom and every statement need words: `speccoder bug \"<what happens>\" --expected \"<what should>\"`"
72 }
73 Self::NoExpected => {
74 "say what should happen instead: `--expected \"…\"` (one per behaviour)"
75 }
76 }
77 }
78}
79
80#[must_use]
83pub fn normalise(text: &str) -> String {
84 text.split_whitespace().collect::<Vec<_>>().join(" ")
85}
86
87impl Triad {
88 pub fn new(
95 current: &str,
96 expected: &[String],
97 unchanged: &[String],
98 hash: impl Fn(&[u8]) -> String,
99 ) -> Result<Self, TriadError> {
100 let current = normalise(current);
101 if current.is_empty() {
102 return Err(TriadError::Empty);
103 }
104 let expected: Vec<String> = expected.iter().map(|e| normalise(e)).collect();
105 let unchanged: Vec<String> = unchanged.iter().map(|u| normalise(u)).collect();
106 if expected.iter().chain(&unchanged).any(String::is_empty) {
107 return Err(TriadError::Empty);
108 }
109 if expected.is_empty() {
110 return Err(TriadError::NoExpected);
111 }
112 let statements = |prefix: char, texts: Vec<String>| -> Vec<Statement> {
113 texts
114 .into_iter()
115 .enumerate()
116 .map(|(i, text)| Statement {
117 id: format!("{prefix}{}", i + 1),
118 text,
119 })
120 .collect()
121 };
122 let mut t = Self {
123 current: Statement {
124 id: "C".to_owned(),
125 text: current,
126 },
127 expected: statements('E', expected),
128 unchanged: statements('U', unchanged),
129 digest: String::new(),
130 };
131 t.digest = format!("sha256:{}", hash(t.canonical().as_bytes()));
132 Ok(t)
133 }
134
135 #[must_use]
137 pub fn canonical(&self) -> String {
138 self.statements()
139 .map(|s| format!("{}\t{}\n", s.id, s.text))
140 .collect()
141 }
142
143 pub fn statements(&self) -> impl Iterator<Item = &Statement> {
145 std::iter::once(&self.current)
146 .chain(&self.expected)
147 .chain(&self.unchanged)
148 }
149
150 #[must_use]
152 pub fn statement(&self, id: &str) -> Option<&Statement> {
153 self.statements().find(|s| s.id == id)
154 }
155
156 #[must_use]
158 pub fn cited(&self) -> Vec<String> {
159 self.expected
160 .iter()
161 .chain(&self.unchanged)
162 .map(|s| s.id.clone())
163 .collect()
164 }
165
166 #[must_use]
169 pub fn labelled(&self) -> String {
170 let mut out = format!("Current: {}\n", self.current.text);
171 for e in &self.expected {
172 out.push_str(&format!("Expected: {}\n", e.text));
173 }
174 for u in &self.unchanged {
175 out.push_str(&format!("Unchanged: {}\n", u.text));
176 }
177 out
178 }
179
180 #[must_use]
182 pub fn title(&self) -> String {
183 let first = self
184 .current
185 .text
186 .split(['.', '!', '?'])
187 .find(|s| !s.trim().is_empty())
188 .unwrap_or(&self.current.text)
189 .trim();
190 let words: Vec<&str> = first.split_whitespace().take(8).collect();
191 let mut t = words.join(" ");
192 if first.split_whitespace().count() > 8 {
193 t.push('…');
194 }
195 t
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
201#[serde(rename_all = "snake_case")]
202pub enum HomeKind {
203 NativeSpeckitBug,
205 NativeKiroBugfix,
207 StandingSpeckit,
209 StandingOpenspec,
211}
212
213impl HomeKind {
214 #[must_use]
217 pub const fn native(self) -> bool {
218 matches!(self, Self::NativeSpeckitBug | Self::NativeKiroBugfix)
219 }
220
221 #[must_use]
223 pub const fn as_str(self) -> &'static str {
224 match self {
225 Self::NativeSpeckitBug => "native_speckit_bug",
226 Self::NativeKiroBugfix => "native_kiro_bugfix",
227 Self::StandingSpeckit => "standing_speckit",
228 Self::StandingOpenspec => "standing_openspec",
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
235pub struct BugHome {
236 pub kind: HomeKind,
238 pub location: String,
240 pub reason: String,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
246#[serde(rename_all = "snake_case")]
247#[schemars(rename = "BugStage")]
248pub enum Stage {
249 Record,
251 Reproduce,
253 Confirm,
255 Unchanged,
257 Fix,
259 Ship,
261 Done,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
267#[serde(rename_all = "snake_case")]
268#[schemars(rename = "BugOutcome")]
269pub enum Outcome {
270 Recorded,
272 AwaitingConfirmation,
274 CannotReproduce,
276 AlreadySatisfied,
278 UnchangedNotHolding,
280 Fixed,
282 Failed,
284}
285
286impl Outcome {
287 #[must_use]
289 pub const fn as_str(self) -> &'static str {
290 match self {
291 Self::Recorded => "recorded",
292 Self::AwaitingConfirmation => "awaiting_confirmation",
293 Self::CannotReproduce => "cannot_reproduce",
294 Self::AlreadySatisfied => "already_satisfied",
295 Self::UnchangedNotHolding => "unchanged_not_holding",
296 Self::Fixed => "fixed",
297 Self::Failed => "failed",
298 }
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
304#[serde(rename_all = "snake_case")]
305#[schemars(rename = "ReproductionSource")]
306pub enum Source {
307 Author,
309 Person,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
315#[serde(rename_all = "snake_case")]
316#[schemars(rename = "FinalState")]
317pub enum Final {
318 Passed,
320 Failed,
322 NotRun,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
328#[serde(rename_all = "snake_case")]
329#[schemars(rename = "RevertOutcome")]
330pub enum Revert {
331 Rejected,
333 Survived,
335 Unviable,
337 NotRun,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
343pub struct Reproduction {
344 pub statement: String,
347 pub file: String,
349 pub test: String,
351 pub source: Source,
353 pub baseline: Vec<RedClass>,
355 pub digest: String,
357 pub argv: Vec<String>,
359 #[serde(rename = "final")]
361 pub final_: Final,
362 pub revert: Revert,
364}
365
366impl Reproduction {
367 #[must_use]
369 pub fn reproduces(&self) -> bool {
370 self.statement.starts_with('E')
371 }
372
373 #[must_use]
376 pub fn red(&self) -> bool {
377 self.baseline.len() >= 2 && self.baseline.iter().all(|c| *c == RedClass::Assertion)
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
383pub struct HeldOutVariant {
384 pub statement: String,
386 pub baseline: RedClass,
388 #[serde(rename = "final")]
390 pub final_: Final,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
395pub struct TryStatement {
396 pub statement: String,
398 pub class: RedClass,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub excerpt: Option<String>,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
408pub struct ReproduceTry {
409 pub n: u32,
411 pub per_statement: Vec<TryStatement>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub discarded: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub run_id: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub usage: Option<serde_json::Value>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub rejection: Option<String>,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq)]
429pub enum Reproduce {
430 Reproduced {
432 n: u32,
434 },
435 AlreadySatisfied,
437 Retry,
439 CannotReproduce {
441 reproduced: Vec<String>,
443 satisfied: Vec<String>,
445 not_demonstrated: Vec<String>,
447 },
448}
449
450fn class_in<'t>(t: &'t ReproduceTry, statement: &str) -> Option<&'t RedClass> {
452 t.per_statement
453 .iter()
454 .find(|s| s.statement == statement)
455 .map(|s| &s.class)
456}
457
458#[must_use]
467pub fn decide_reproduce(tries: &[ReproduceTry], expected: &[String], max: u32) -> Reproduce {
468 if let Some(t) = tries.last().filter(|t| {
469 t.discarded.is_none()
470 && !expected.is_empty()
471 && expected
472 .iter()
473 .all(|e| class_in(t, e) == Some(&RedClass::Assertion))
474 }) {
475 return Reproduce::Reproduced { n: t.n };
476 }
477 let made = u32::try_from(tries.len()).unwrap_or(u32::MAX);
478 if made < max.max(1) {
479 return Reproduce::Retry;
480 }
481 let all_passed = !tries.is_empty()
482 && tries.iter().all(|t| {
483 t.discarded.is_none()
484 && expected
485 .iter()
486 .all(|e| class_in(t, e) == Some(&RedClass::Passed))
487 });
488 if all_passed {
489 return Reproduce::AlreadySatisfied;
490 }
491 let mut reproduced = Vec::new();
492 let mut satisfied = Vec::new();
493 let mut not_demonstrated = Vec::new();
494 for e in expected {
495 let classes: Vec<&RedClass> = tries
496 .iter()
497 .filter(|t| t.discarded.is_none())
498 .filter_map(|t| class_in(t, e))
499 .collect();
500 if classes.contains(&&RedClass::Assertion) {
501 reproduced.push(e.clone());
502 } else if !classes.is_empty() && classes.iter().all(|c| **c == RedClass::Passed) {
503 satisfied.push(e.clone());
504 } else {
505 not_demonstrated.push(e.clone());
506 }
507 }
508 Reproduce::CannotReproduce {
509 reproduced,
510 satisfied,
511 not_demonstrated,
512 }
513}
514
515#[must_use]
517pub fn rejected_try(reason: &str) -> String {
518 format!("rejected by the person: {reason}")
519}
520
521pub const MAX_SLUG: usize = 64;
523
524#[must_use]
529pub fn valid_slug(slug: &str) -> bool {
530 !slug.is_empty()
531 && slug.len() <= MAX_SLUG
532 && slug
533 .bytes()
534 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
535 && slug
536 .bytes()
537 .next()
538 .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
539}
540
541#[must_use]
545pub fn bug_class(first: RedClass, second: Option<RedClass>) -> RedClass {
546 match (first, second) {
547 (RedClass::Assertion, Some(RedClass::Assertion)) => RedClass::Assertion,
548 (RedClass::Assertion, _) => RedClass::Flaky,
549 (c, _) => c,
550 }
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
555#[serde(rename_all = "snake_case")]
556pub enum Basis {
557 Shown,
559 NamedTest,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
565pub struct Confirmation {
566 pub digest: String,
569 pub triad_digest: String,
571 pub by: String,
573 pub via: Via,
575 pub basis: Basis,
577 pub at: u64,
579}
580
581impl Confirmation {
582 #[must_use]
584 pub fn current(&self, triad_digest: &str) -> bool {
585 self.triad_digest == triad_digest
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
592pub struct Rejection {
593 pub digest: String,
595 pub reason: String,
597 pub by: String,
599 pub via: Via,
601 pub at: u64,
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
607#[serde(rename_all = "snake_case")]
608pub enum PassToPassState {
609 Selected,
611 NoneSelected,
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
617#[serde(rename_all = "snake_case")]
618pub enum MemberFinal {
619 Passed,
621 Failed,
623 Missing,
625 NotRun,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
631#[schemars(rename = "PassToPassMember")]
632pub struct Member {
633 pub test: String,
635 pub file: String,
637 pub rule: String,
640 #[serde(rename = "final")]
642 pub final_: MemberFinal,
643}
644
645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
647pub struct PassToPass {
648 pub state: PassToPassState,
650 pub members: Vec<Member>,
652}
653
654impl PassToPass {
655 #[must_use]
658 pub fn held(&self) -> bool {
659 self.members.iter().all(|m| m.final_ == MemberFinal::Passed)
660 }
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
665pub struct RevertResult {
666 pub outcome: Revert,
668 pub rejected_by: Vec<String>,
670}
671
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
675pub struct SpecialCase {
676 pub statement: String,
678 pub digest: String,
680 pub rejected: bool,
682 pub regenerated: bool,
684 #[serde(default)]
686 pub ruled: bool,
687}
688
689#[derive(Debug, Clone, Copy)]
692pub struct Discrimination<'a> {
693 pub reproductions: &'a [Reproduction],
695 pub revert: &'a RevertResult,
697 pub special_case: &'a [SpecialCase],
699 pub special_case_error: Option<&'a str>,
702}
703
704#[must_use]
711pub fn bug_verdict(
712 base: Verdict,
713 d: &Discrimination<'_>,
714 p2p: &PassToPass,
715 held_out: &HeldOut,
716) -> Verdict {
717 let mut reasons = Vec::new();
718 for r in d.reproductions {
719 if r.final_ != Final::Passed {
720 reasons.push(format!(
721 "the reproduction of {} does not pass on the fixed tree",
722 r.statement
723 ));
724 }
725 }
726 if d.revert.rejected_by.is_empty() {
727 reasons.push(match d.revert.outcome {
728 Revert::Unviable => "the reproduction does not discriminate the fix: the revert does not build, and no reproduction rejects it by an assertion".to_owned(),
729 _ => "the reproduction does not discriminate the fix: with the change undone the reproductions still pass".to_owned(),
730 });
731 }
732 let mut reproducing: Vec<&str> = d
735 .reproductions
736 .iter()
737 .filter(|r| r.reproduces())
738 .map(|r| r.statement.as_str())
739 .collect();
740 reproducing.dedup();
741 for st in reproducing {
742 if !d.special_case.iter().any(|s| s.statement == st) {
743 reasons.push(format!(
744 "no special-case candidate for {st} ({}): the reproduction's strength is not shown",
745 d.special_case_error
746 .unwrap_or("the special-case author wrote none")
747 ));
748 }
749 }
750 for s in d.special_case {
751 if !s.rejected && !s.ruled {
752 reasons.push(format!(
753 "reproduction too weak: a fix that special-cases {}'s input passes the reproductions and the held-out variants",
754 s.statement
755 ));
756 }
757 }
758 for m in &p2p.members {
759 match m.final_ {
760 MemberFinal::Passed => {}
761 MemberFinal::Missing => {
762 reasons.push(format!("pass-to-pass: {} is missing", m.test));
763 }
764 MemberFinal::Failed | MemberFinal::NotRun => {
765 reasons.push(format!("pass-to-pass: {} does not pass", m.test));
766 }
767 }
768 }
769 match held_out {
770 HeldOut::Passed { .. } | HeldOut::Off => {}
771 HeldOut::Absent { reason } => reasons.push(format!(
772 "no held-out variant was kept ({reason}): the fix cannot be checked on other inputs"
773 )),
774 HeldOut::Failed { scenarios } => reasons.push(format!(
775 "held-out variant failed for {}",
776 scenarios.join(", ")
777 )),
778 }
779 match base {
780 Verdict::Failed {
781 reasons: mut before,
782 } => {
783 for r in reasons {
784 if !before.contains(&r) {
785 before.push(r);
786 }
787 }
788 Verdict::Failed { reasons: before }
789 }
790 v if reasons.is_empty() => v,
791 _ => Verdict::Failed { reasons },
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
797#[schemars(rename = "AuthorClaim")]
798pub struct Claim {
799 pub text: String,
801 pub by: String,
803}
804
805#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
807#[schemars(rename = "StatementsShown")]
808pub struct Statements {
809 pub reproduced: Vec<String>,
811 pub not_reproduced: Vec<String>,
813 pub satisfied: Vec<String>,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
820pub struct Handoff {
821 pub outcome: Outcome,
823 pub tries: Vec<ReproduceTry>,
825 pub statements: Statements,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub cost: Option<serde_json::Value>,
830 pub elapsed_ms: u64,
832 pub next: Vec<NextStep>,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub claim: Option<Claim>,
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
841#[serde(rename_all = "snake_case")]
842pub enum CandidateOrigin {
843 Patch,
845 Pr,
847}
848
849#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
851pub struct CandidateRefusal {
852 pub code: String,
854 pub paths: Vec<String>,
856}
857
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
860pub struct Candidate {
861 pub origin: CandidateOrigin,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
865 pub path: Option<String>,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub number: Option<u64>,
869 pub digest: String,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub head_sha: Option<String>,
874 pub applied: bool,
876 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub refused: Option<CandidateRefusal>,
879}
880
881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
883pub struct IssueRef {
884 pub number: u64,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub repo: Option<String>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
892 pub context_digest: Option<String>,
893}
894
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
897pub struct PreFrozenRef {
898 pub manifest: String,
900 pub approval: String,
902 pub wording: String,
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
908pub struct AcceptanceRef {
909 pub source: crate::acceptance::AcceptanceSource,
911 pub pre_frozen: PreFrozenRef,
913}
914
915#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
918pub struct BugReport {
919 pub home: BugHome,
921 pub unit: String,
923 pub requirement: String,
925 pub task: String,
927 pub title: String,
929 pub triad: Triad,
931 pub stage: Stage,
933 pub outcome: Outcome,
935 #[serde(default)]
937 pub verdict: Option<Verdict>,
938 #[serde(default)]
940 pub code: Option<String>,
941 #[serde(default)]
943 pub reproductions: Vec<Reproduction>,
944 #[serde(default)]
946 pub held_out: Vec<HeldOutVariant>,
947 #[serde(default)]
949 pub held_out_sealed: Option<crate::acceptance::SealedRef>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub held_out_reason: Option<String>,
954 #[serde(default)]
956 pub confirm_digest: Option<String>,
957 #[serde(default)]
959 pub confirmation: Option<Confirmation>,
960 #[serde(default)]
962 pub rejections: Vec<Rejection>,
963 #[serde(default)]
965 pub acceptance: Option<AcceptanceRef>,
966 #[serde(default)]
968 pub pass_to_pass: Option<PassToPass>,
969 #[serde(default)]
971 pub revert: Option<RevertResult>,
972 #[serde(default)]
974 pub special_case: Vec<SpecialCase>,
975 #[serde(default)]
977 pub tries: Vec<ReproduceTry>,
978 pub max_tries: u32,
980 #[serde(default)]
982 pub handoff: Option<Handoff>,
983 #[serde(default)]
985 pub issue: Option<IssueRef>,
986 #[serde(default)]
988 pub candidate: Option<Candidate>,
989 #[serde(default)]
991 pub recorded_commit: Option<String>,
992 #[serde(default)]
994 pub commit: Option<String>,
995 #[serde(default)]
997 pub run_id: Option<String>,
998}
999
1000impl BugReport {
1001 #[must_use]
1003 pub fn expected_ids(&self) -> Vec<String> {
1004 self.triad.expected.iter().map(|s| s.id.clone()).collect()
1005 }
1006
1007 #[must_use]
1009 pub fn key(&self) -> String {
1010 format!("{}/{}", self.unit, self.task)
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use proptest::prelude::*;
1017
1018 use super::*;
1019
1020 fn hash(b: &[u8]) -> String {
1021 format!(
1023 "{:016x}",
1024 b.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, x| {
1025 (h ^ u64::from(*x)).wrapping_mul(0x0100_0000_01b3)
1026 })
1027 )
1028 }
1029
1030 fn strings(v: &[&str]) -> Vec<String> {
1031 v.iter().map(|s| (*s).to_owned()).collect()
1032 }
1033
1034 #[test]
1035 fn a_triad_numbers_its_statements_and_refuses_empty_words() {
1036 let t = Triad::new(
1037 " saving an empty title crashes ",
1038 &strings(&["an empty title is refused", "the form keeps its text"]),
1039 &strings(&["a title is saved"]),
1040 hash,
1041 )
1042 .unwrap_or_else(|e| panic!("{e:?}"));
1043 assert_eq!(t.current.text, "saving an empty title crashes");
1044 assert_eq!(t.cited(), ["E1", "E2", "U1"]);
1045 assert!(t.digest.starts_with("sha256:"));
1046 assert_eq!(
1047 t.labelled(),
1048 "Current: saving an empty title crashes\nExpected: an empty title is refused\nExpected: the form keeps its text\nUnchanged: a title is saved\n"
1049 );
1050 assert_eq!(t.title(), "saving an empty title crashes");
1051 assert_eq!(
1052 Triad::new(" ", &strings(&["x"]), &[], hash),
1053 Err(TriadError::Empty)
1054 );
1055 assert_eq!(Triad::new("x", &[], &[], hash), Err(TriadError::NoExpected));
1056 assert_eq!(
1057 Triad::new("x", &strings(&[" "]), &[], hash),
1058 Err(TriadError::Empty)
1059 );
1060 assert_eq!(TriadError::NoExpected.code(), "BUG-NO-EXPECTED");
1061 }
1062
1063 fn try_of(n: u32, classes: &[(&str, RedClass)]) -> ReproduceTry {
1064 ReproduceTry {
1065 n,
1066 per_statement: classes
1067 .iter()
1068 .map(|(s, c)| TryStatement {
1069 statement: (*s).to_owned(),
1070 class: *c,
1071 excerpt: None,
1072 })
1073 .collect(),
1074 discarded: None,
1075 run_id: None,
1076 usage: None,
1077 rejection: None,
1078 }
1079 }
1080
1081 fn red_class() -> impl Strategy<Value = RedClass> {
1082 prop_oneof![
1083 Just(RedClass::Assertion),
1084 Just(RedClass::Absent),
1085 Just(RedClass::BuildError),
1086 Just(RedClass::CollectionError),
1087 Just(RedClass::Timeout),
1088 Just(RedClass::NoTests),
1089 Just(RedClass::Flaky),
1090 Just(RedClass::Unclassified),
1091 Just(RedClass::Passed),
1092 ]
1093 }
1094
1095 proptest! {
1096 #[test]
1097 fn reproduce_is_decided_by_classes_alone(
1098 classes in proptest::collection::vec(proptest::collection::vec(red_class(), 2), 1..6),
1099 max in 1u32..6,
1100 ) {
1101 let expected = strings(&["E1", "E2"]);
1102 let tries: Vec<ReproduceTry> = classes
1103 .iter()
1104 .enumerate()
1105 .map(|(i, c)| try_of(u32::try_from(i).unwrap_or(0) + 1, &[("E1", c[0]), ("E2", c[1])]))
1106 .collect();
1107 let all_red = tries.last().is_some_and(|t| t.per_statement.iter().all(|s| s.class == RedClass::Assertion));
1109 match decide_reproduce(&tries, &expected, max) {
1110 Reproduce::Reproduced { n } => {
1111 prop_assert!(all_red);
1112 prop_assert_eq!(n as usize, tries.len());
1113 let t = &tries[(n - 1) as usize];
1114 prop_assert!(t.per_statement.iter().all(|s| s.class == RedClass::Assertion));
1116 }
1117 Reproduce::AlreadySatisfied => {
1118 prop_assert!(!all_red);
1119 prop_assert!(tries.len() >= max as usize);
1120 prop_assert!(tries.iter().all(|t| t.per_statement.iter().all(|s| s.class == RedClass::Passed)));
1121 }
1122 Reproduce::Retry => {
1123 prop_assert!(!all_red);
1124 prop_assert!(tries.len() < max as usize);
1125 }
1126 Reproduce::CannotReproduce { reproduced, satisfied, not_demonstrated } => {
1127 prop_assert!(!all_red);
1128 prop_assert!(tries.len() >= max as usize);
1129 let mut named: Vec<String> = reproduced.iter().chain(&satisfied).chain(¬_demonstrated).cloned().collect();
1130 named.sort();
1131 prop_assert_eq!(named, expected.clone());
1132 }
1133 }
1134 }
1135
1136 #[test]
1137 fn a_bug_is_red_only_by_two_assertions(a in red_class(), b in red_class()) {
1138 let c = bug_class(a, Some(b));
1139 prop_assert_eq!(c == RedClass::Assertion, a == RedClass::Assertion && b == RedClass::Assertion);
1140 }
1141
1142 #[test]
1143 fn verified_needs_every_bug_rule(
1144 finals in proptest::collection::vec(prop_oneof![Just(Final::Passed), Just(Final::Failed), Just(Final::NotRun)], 1..4),
1145 rejected_by in proptest::collection::vec("E[1-3]", 0..3),
1146 special in proptest::collection::vec((any::<bool>(), any::<bool>()), 0..3),
1147 members in proptest::collection::vec(prop_oneof![Just(MemberFinal::Passed), Just(MemberFinal::Failed), Just(MemberFinal::Missing)], 0..4),
1148 held in prop_oneof![
1149 Just(HeldOut::Passed { files: 1 }),
1150 Just(HeldOut::Failed { scenarios: vec!["E1".to_owned()] }),
1151 Just(HeldOut::Absent { reason: "all_discarded".to_owned() }),
1152 Just(HeldOut::Off),
1153 ],
1154 ) {
1155 let reproductions: Vec<Reproduction> = finals.iter().enumerate().map(|(i, f)| Reproduction {
1156 statement: format!("E{}", i + 1),
1157 file: format!("tests/repro_{i}.rs"),
1158 test: "t".to_owned(),
1159 source: Source::Author,
1160 baseline: vec![RedClass::Assertion, RedClass::Assertion],
1161 digest: "sha256:x".to_owned(),
1162 argv: Vec::new(),
1163 final_: *f,
1164 revert: Revert::NotRun,
1165 }).collect();
1166 let revert = RevertResult { outcome: if rejected_by.is_empty() { Revert::Survived } else { Revert::Rejected }, rejected_by: rejected_by.clone() };
1167 let special_case: Vec<SpecialCase> = special.iter().enumerate().map(|(i, (rejected, ruled))| SpecialCase {
1168 statement: format!("E{}", i + 1), digest: "sha256:s".to_owned(), rejected: *rejected, regenerated: false, ruled: *ruled,
1169 }).collect();
1170 let p2p = PassToPass {
1171 state: if members.is_empty() { PassToPassState::NoneSelected } else { PassToPassState::Selected },
1172 members: members.iter().map(|m| Member { test: "held_out_secret_name".to_owned(), file: "tests/a.rs".to_owned(), rule: "same_file".to_owned(), final_: *m }).collect(),
1173 };
1174 let d = Discrimination { reproductions: &reproductions, revert: &revert, special_case: &special_case, special_case_error: None };
1175 let v = bug_verdict(Verdict::Verified, &d, &p2p, &held);
1176 let ok = finals.iter().all(|f| *f == Final::Passed)
1177 && !rejected_by.is_empty()
1178 && (1..=finals.len()).all(|i| special_case.iter().any(|s| s.statement == format!("E{i}")))
1179 && special.iter().all(|(r, ruled)| *r || *ruled)
1180 && members.iter().all(|m| *m == MemberFinal::Passed)
1181 && matches!(held, HeldOut::Passed { .. } | HeldOut::Off);
1182 prop_assert_eq!(v == Verdict::Verified, ok);
1183 if let Verdict::Failed { reasons } = &v {
1184 prop_assert!(reasons.iter().all(|r| !r.contains("held_out_") || r.starts_with("pass-to-pass")));
1186 }
1187 let failed = bug_verdict(Verdict::Failed { reasons: vec!["check `x` failed".to_owned()] }, &d, &p2p, &held);
1189 prop_assert!(matches!(failed, Verdict::Failed { .. }), "a failed verdict stays failed");
1190 }
1191 }
1192
1193 #[test]
1194 fn absent_and_flaky_are_not_a_bugs_red() {
1195 assert_eq!(
1196 bug_class(RedClass::Absent, Some(RedClass::Absent)),
1197 RedClass::Absent
1198 );
1199 assert_eq!(
1200 bug_class(RedClass::Assertion, Some(RedClass::Passed)),
1201 RedClass::Flaky
1202 );
1203 assert_eq!(bug_class(RedClass::Assertion, None), RedClass::Flaky);
1204 let tries = [try_of(1, &[("E1", RedClass::Absent)])];
1205 assert!(matches!(
1206 decide_reproduce(&tries, &strings(&["E1"]), 1),
1207 Reproduce::CannotReproduce { .. }
1208 ));
1209 }
1210
1211 #[test]
1212 fn a_rejected_try_is_discarded_and_the_tries_stay_bounded() {
1213 let e1 = strings(&["E1"]);
1214 let mut rejected = try_of(1, &[("E1", RedClass::Assertion)]);
1215 rejected.discarded = Some(rejected_try("it tests the wrong field"));
1216 assert_eq!(
1218 decide_reproduce(std::slice::from_ref(&rejected), &e1, 3),
1219 Reproduce::Retry
1220 );
1221 let again = try_of(2, &[("E1", RedClass::Assertion)]);
1222 assert_eq!(
1223 decide_reproduce(&[rejected.clone(), again], &e1, 3),
1224 Reproduce::Reproduced { n: 2 }
1225 );
1226 let all: Vec<ReproduceTry> = (1..=3)
1228 .map(|n| ReproduceTry {
1229 n,
1230 ..rejected.clone()
1231 })
1232 .collect();
1233 assert_eq!(
1234 decide_reproduce(&all, &e1, 3),
1235 Reproduce::CannotReproduce {
1236 reproduced: Vec::new(),
1237 satisfied: Vec::new(),
1238 not_demonstrated: e1.clone(),
1239 }
1240 );
1241 let tries = [
1243 try_of(1, &[("E1", RedClass::Assertion)]),
1244 try_of(2, &[("E1", RedClass::BuildError)]),
1245 ];
1246 assert_eq!(decide_reproduce(&tries, &e1, 3), Reproduce::Retry);
1247 }
1248
1249 #[test]
1250 fn a_missing_special_case_withholds_verified() {
1251 let reproductions = vec![Reproduction {
1252 statement: "E1".to_owned(),
1253 file: "tests/repro.rs".to_owned(),
1254 test: "t".to_owned(),
1255 source: Source::Author,
1256 baseline: vec![RedClass::Assertion, RedClass::Assertion],
1257 digest: "sha256:x".to_owned(),
1258 argv: Vec::new(),
1259 final_: Final::Passed,
1260 revert: Revert::Rejected,
1261 }];
1262 let revert = RevertResult {
1263 outcome: Revert::Rejected,
1264 rejected_by: strings(&["E1"]),
1265 };
1266 let p2p = PassToPass {
1267 state: PassToPassState::NoneSelected,
1268 members: Vec::new(),
1269 };
1270 let d = Discrimination {
1271 reproductions: &reproductions,
1272 revert: &revert,
1273 special_case: &[],
1274 special_case_error: Some("the author call failed: timeout"),
1275 };
1276 let Verdict::Failed { reasons } =
1277 bug_verdict(Verdict::Verified, &d, &p2p, &HeldOut::Passed { files: 1 })
1278 else {
1279 panic!("verified without a special case");
1280 };
1281 assert!(
1282 reasons
1283 .iter()
1284 .any(|r| r.contains("E1") && r.contains("the author call failed: timeout")),
1285 "{reasons:?}"
1286 );
1287 }
1288
1289 #[test]
1290 fn a_slug_is_never_a_path() {
1291 for ok in ["empty-title-crash", "a", "0-x", "saving-an-empty-title-2"] {
1292 assert!(valid_slug(ok), "{ok}");
1293 }
1294 for bad in [
1295 "",
1296 "..",
1297 "../x",
1298 "a/b",
1299 "a\\b",
1300 "-x",
1301 "A",
1302 "a b",
1303 "a.b",
1304 "é",
1305 &"a".repeat(MAX_SLUG + 1),
1306 ] {
1307 assert!(!valid_slug(bad), "{bad}");
1308 }
1309 }
1310
1311 #[test]
1312 fn mixed_tries_name_what_was_satisfied() {
1313 let tries = [
1314 try_of(1, &[("E1", RedClass::Assertion), ("E2", RedClass::Passed)]),
1315 try_of(2, &[("E1", RedClass::BuildError), ("E2", RedClass::Passed)]),
1316 ];
1317 assert_eq!(
1318 decide_reproduce(&tries, &strings(&["E1", "E2"]), 2),
1319 Reproduce::CannotReproduce {
1320 reproduced: strings(&["E1"]),
1321 satisfied: strings(&["E2"]),
1322 not_demonstrated: Vec::new(),
1323 }
1324 );
1325 let passed = [
1326 try_of(1, &[("E1", RedClass::Passed)]),
1327 try_of(2, &[("E1", RedClass::Passed)]),
1328 ];
1329 assert_eq!(
1330 decide_reproduce(&passed, &strings(&["E1"]), 2),
1331 Reproduce::AlreadySatisfied
1332 );
1333 assert_eq!(
1334 decide_reproduce(&passed[..1], &strings(&["E1"]), 2),
1335 Reproduce::Retry
1336 );
1337 }
1338}