@@ -30,12 +30,33 @@ impl std::fmt::Display for GreenLevel {
3030#[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize , Deserialize ) ]
3131pub struct GreenContract {
3232 pub required_level : GreenLevel ,
33+ pub require_test_command_provenance : bool ,
34+ pub require_base_branch_freshness : bool ,
35+ pub require_recovery_attempt_context : bool ,
36+ pub block_known_flakes : bool ,
3337}
3438
3539impl GreenContract {
3640 #[ must_use]
3741 pub fn new ( required_level : GreenLevel ) -> Self {
38- Self { required_level }
42+ Self {
43+ required_level,
44+ require_test_command_provenance : false ,
45+ require_base_branch_freshness : false ,
46+ require_recovery_attempt_context : false ,
47+ block_known_flakes : false ,
48+ }
49+ }
50+
51+ #[ must_use]
52+ pub fn merge_ready ( required_level : GreenLevel ) -> Self {
53+ Self {
54+ required_level,
55+ require_test_command_provenance : true ,
56+ require_base_branch_freshness : true ,
57+ require_recovery_attempt_context : true ,
58+ block_known_flakes : true ,
59+ }
3960 }
4061
4162 #[ must_use]
@@ -52,12 +73,164 @@ impl GreenContract {
5273 }
5374 }
5475
76+ #[ must_use]
77+ pub fn evaluate_evidence ( & self , evidence : & GreenEvidence ) -> GreenEvidenceOutcome {
78+ let mut missing = Vec :: new ( ) ;
79+ let mut blocking_flakes = Vec :: new ( ) ;
80+
81+ if evidence. observed_level < self . required_level {
82+ missing. push ( GreenContractRequirement :: RequiredLevel ) ;
83+ }
84+
85+ if self . require_test_command_provenance && !evidence. has_passing_test_command ( ) {
86+ missing. push ( GreenContractRequirement :: TestCommandProvenance ) ;
87+ }
88+
89+ if self . require_base_branch_freshness && !evidence. base_branch_fresh {
90+ missing. push ( GreenContractRequirement :: BaseBranchFreshness ) ;
91+ }
92+
93+ if self . require_recovery_attempt_context && !evidence. recovery_attempt_context_recorded {
94+ missing. push ( GreenContractRequirement :: RecoveryAttemptContext ) ;
95+ }
96+
97+ if self . block_known_flakes {
98+ blocking_flakes = evidence
99+ . known_flakes
100+ . iter ( )
101+ . filter ( |flake| flake. blocks_green )
102+ . cloned ( )
103+ . collect ( ) ;
104+ }
105+
106+ if missing. is_empty ( ) && blocking_flakes. is_empty ( ) {
107+ GreenEvidenceOutcome :: Satisfied {
108+ required_level : self . required_level ,
109+ observed_level : evidence. observed_level ,
110+ }
111+ } else {
112+ GreenEvidenceOutcome :: Unsatisfied {
113+ required_level : self . required_level ,
114+ observed_level : evidence. observed_level ,
115+ missing,
116+ blocking_flakes,
117+ }
118+ }
119+ }
120+
55121 #[ must_use]
56122 pub fn is_satisfied_by ( self , observed_level : GreenLevel ) -> bool {
57123 observed_level >= self . required_level
58124 }
59125}
60126
127+ #[ derive( Debug , Clone , PartialEq , Eq , Serialize , Deserialize ) ]
128+ pub struct GreenEvidence {
129+ pub observed_level : GreenLevel ,
130+ pub test_commands : Vec < TestCommandProvenance > ,
131+ pub base_branch_fresh : bool ,
132+ pub known_flakes : Vec < KnownFlake > ,
133+ pub recovery_attempt_context_recorded : bool ,
134+ }
135+
136+ impl GreenEvidence {
137+ #[ must_use]
138+ pub fn new ( observed_level : GreenLevel ) -> Self {
139+ Self {
140+ observed_level,
141+ test_commands : Vec :: new ( ) ,
142+ base_branch_fresh : false ,
143+ known_flakes : Vec :: new ( ) ,
144+ recovery_attempt_context_recorded : false ,
145+ }
146+ }
147+
148+ #[ must_use]
149+ pub fn with_test_command ( mut self , command : impl Into < String > , exit_code : i32 ) -> Self {
150+ self . test_commands . push ( TestCommandProvenance {
151+ command : command. into ( ) ,
152+ exit_code,
153+ } ) ;
154+ self
155+ }
156+
157+ #[ must_use]
158+ pub fn with_base_branch_fresh ( mut self , is_fresh : bool ) -> Self {
159+ self . base_branch_fresh = is_fresh;
160+ self
161+ }
162+
163+ #[ must_use]
164+ pub fn with_known_flake ( mut self , test_name : impl Into < String > , blocks_green : bool ) -> Self {
165+ self . known_flakes . push ( KnownFlake {
166+ test_name : test_name. into ( ) ,
167+ blocks_green,
168+ } ) ;
169+ self
170+ }
171+
172+ #[ must_use]
173+ pub fn with_recovery_attempt_context ( mut self , recorded : bool ) -> Self {
174+ self . recovery_attempt_context_recorded = recorded;
175+ self
176+ }
177+
178+ #[ must_use]
179+ pub fn has_passing_test_command ( & self ) -> bool {
180+ self . test_commands . iter ( ) . any ( TestCommandProvenance :: passed)
181+ }
182+ }
183+
184+ #[ derive( Debug , Clone , PartialEq , Eq , Serialize , Deserialize ) ]
185+ pub struct TestCommandProvenance {
186+ pub command : String ,
187+ pub exit_code : i32 ,
188+ }
189+
190+ impl TestCommandProvenance {
191+ #[ must_use]
192+ pub fn passed ( & self ) -> bool {
193+ self . exit_code == 0 && !self . command . trim ( ) . is_empty ( )
194+ }
195+ }
196+
197+ #[ derive( Debug , Clone , PartialEq , Eq , Serialize , Deserialize ) ]
198+ pub struct KnownFlake {
199+ pub test_name : String ,
200+ pub blocks_green : bool ,
201+ }
202+
203+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize , Deserialize ) ]
204+ #[ serde( rename_all = "snake_case" ) ]
205+ pub enum GreenContractRequirement {
206+ RequiredLevel ,
207+ TestCommandProvenance ,
208+ BaseBranchFreshness ,
209+ RecoveryAttemptContext ,
210+ }
211+
212+ #[ derive( Debug , Clone , PartialEq , Eq , Serialize , Deserialize ) ]
213+ #[ serde( tag = "outcome" , rename_all = "snake_case" ) ]
214+ pub enum GreenEvidenceOutcome {
215+ Satisfied {
216+ required_level : GreenLevel ,
217+ observed_level : GreenLevel ,
218+ } ,
219+ Unsatisfied {
220+ required_level : GreenLevel ,
221+ observed_level : GreenLevel ,
222+ missing : Vec < GreenContractRequirement > ,
223+ blocking_flakes : Vec < KnownFlake > ,
224+ } ,
225+ }
226+
227+ impl GreenEvidenceOutcome {
228+ #[ must_use]
229+ pub fn is_satisfied ( & self ) -> bool {
230+ matches ! ( self , Self :: Satisfied { .. } )
231+ }
232+ }
233+
61234#[ derive( Debug , Clone , PartialEq , Eq , Serialize , Deserialize ) ]
62235#[ serde( tag = "outcome" , rename_all = "snake_case" ) ]
63236pub enum GreenContractOutcome {
@@ -149,4 +322,83 @@ mod tests {
149322 }
150323 ) ;
151324 }
325+ #[ test]
326+ fn merge_ready_contract_requires_provenance_beyond_test_level ( ) {
327+ // given
328+ let contract = GreenContract :: merge_ready ( GreenLevel :: Workspace ) ;
329+ let evidence = GreenEvidence :: new ( GreenLevel :: Workspace )
330+ . with_test_command ( "cargo test --manifest-path rust/Cargo.toml" , 0 ) ;
331+
332+ // when
333+ let outcome = contract. evaluate_evidence ( & evidence) ;
334+
335+ // then
336+ assert_eq ! (
337+ outcome,
338+ GreenEvidenceOutcome :: Unsatisfied {
339+ required_level: GreenLevel :: Workspace ,
340+ observed_level: GreenLevel :: Workspace ,
341+ missing: vec![
342+ GreenContractRequirement :: BaseBranchFreshness ,
343+ GreenContractRequirement :: RecoveryAttemptContext ,
344+ ] ,
345+ blocking_flakes: vec![ ] ,
346+ }
347+ ) ;
348+ assert ! ( !outcome. is_satisfied( ) ) ;
349+ }
350+
351+ #[ test]
352+ fn merge_ready_contract_accepts_complete_test_provenance_context ( ) {
353+ // given
354+ let contract = GreenContract :: merge_ready ( GreenLevel :: Workspace ) ;
355+ let evidence = GreenEvidence :: new ( GreenLevel :: MergeReady )
356+ . with_test_command ( "cargo test --manifest-path rust/Cargo.toml" , 0 )
357+ . with_base_branch_fresh ( true )
358+ . with_recovery_attempt_context ( true ) ;
359+
360+ // when
361+ let outcome = contract. evaluate_evidence ( & evidence) ;
362+
363+ // then
364+ assert_eq ! (
365+ outcome,
366+ GreenEvidenceOutcome :: Satisfied {
367+ required_level: GreenLevel :: Workspace ,
368+ observed_level: GreenLevel :: MergeReady ,
369+ }
370+ ) ;
371+ }
372+
373+ #[ test]
374+ fn known_blocking_flake_prevents_green_contract_satisfaction ( ) {
375+ // given
376+ let contract = GreenContract :: merge_ready ( GreenLevel :: Workspace ) ;
377+ let evidence = GreenEvidence :: new ( GreenLevel :: MergeReady )
378+ . with_test_command ( "cargo test --manifest-path rust/Cargo.toml" , 0 )
379+ . with_base_branch_fresh ( true )
380+ . with_recovery_attempt_context ( true )
381+ . with_known_flake (
382+ "session_lifecycle_prefers_running_process_over_idle_shell" ,
383+ true ,
384+ ) ;
385+
386+ // when
387+ let outcome = contract. evaluate_evidence ( & evidence) ;
388+
389+ // then
390+ assert_eq ! (
391+ outcome,
392+ GreenEvidenceOutcome :: Unsatisfied {
393+ required_level: GreenLevel :: Workspace ,
394+ observed_level: GreenLevel :: MergeReady ,
395+ missing: vec![ ] ,
396+ blocking_flakes: vec![ KnownFlake {
397+ test_name: "session_lifecycle_prefers_running_process_over_idle_shell"
398+ . to_string( ) ,
399+ blocks_green: true ,
400+ } ] ,
401+ }
402+ ) ;
403+ }
152404}
0 commit comments