33from __future__ import annotations
44
55import argparse
6+ import json
67import os
78import sys
9+ from typing import Any
810
911from flake_bisect import __version__
1012from flake_bisect .bisect import ddmin
@@ -50,6 +52,18 @@ def _build_parser() -> argparse.ArgumentParser:
5052 metavar = "ARG" ,
5153 help = "Extra argument forwarded to pytest. Repeat to pass multiple." ,
5254 )
55+ p .add_argument (
56+ "--json" ,
57+ dest = "json_path" ,
58+ default = None ,
59+ metavar = "PATH" ,
60+ help = "Write a machine-readable JSON report to PATH (use '-' for stdout)." ,
61+ )
62+ p .add_argument (
63+ "--no-confirm" ,
64+ action = "store_true" ,
65+ help = "Skip the post-bisect confirmation runs (saves two pytest invocations)." ,
66+ )
5367 p .add_argument (
5468 "-v" ,
5569 "--verbose" ,
@@ -59,12 +73,46 @@ def _build_parser() -> argparse.ArgumentParser:
5973 return p
6074
6175
76+ def _emit_json (path : str , payload : dict [str , Any ]) -> None :
77+ """Write the JSON report to a file, or to stdout when path == '-'."""
78+ text = json .dumps (payload , indent = 2 , sort_keys = True )
79+ if path == "-" :
80+ print (text )
81+ else :
82+ with open (path , "w" , encoding = "utf-8" ) as fh :
83+ fh .write (text + "\n " )
84+
85+
6286def main (argv : list [str ] | None = None ) -> int :
6387 args = _build_parser ().parse_args (argv )
6488
6589 workdir = os .path .abspath (args .workdir )
6690 target = args .target
6791 extra = list (args .pytest_arg or [])
92+ json_path = args .json_path
93+
94+ # Total pytest subprocess invocations across every phase (collection excluded).
95+ runs = {"total" : 0 }
96+
97+ def _run (order : list [str ]):
98+ runs ["total" ] += 1
99+ return run_ordered (workdir , order , extra )
100+
101+ def _report (status : str , exit_code : int , ** fields : Any ) -> int :
102+ if json_path :
103+ payload : dict [str , Any ] = {
104+ "tool" : "flake-bisect" ,
105+ "version" : __version__ ,
106+ "status" : status ,
107+ "exit_code" : exit_code ,
108+ "target" : target ,
109+ "workdir" : workdir ,
110+ "pytest_invocations" : runs ["total" ],
111+ "max_runs" : args .max_runs ,
112+ }
113+ payload .update (fields )
114+ _emit_json (json_path , payload )
115+ return exit_code
68116
69117 testpaths = args .testpaths if args .testpaths else []
70118 print (f"flake-bisect { __version__ } " , file = sys .stderr )
@@ -75,59 +123,59 @@ def main(argv: list[str] | None = None) -> int:
75123 all_ids = collect_tests (workdir , testpaths )
76124 if not all_ids :
77125 print ("ERROR: no tests collected" , file = sys .stderr )
78- return 2
126+ return _report ( "no_tests_collected" , 2 )
79127 if target not in all_ids :
80128 print (
81129 f"ERROR: target nodeid not found in collection: { target } \n "
82130 f"Got { len (all_ids )} nodeids; first few:\n "
83131 + "\n " .join (all_ids [:5 ]),
84132 file = sys .stderr ,
85133 )
86- return 2
134+ return _report ( "target_not_found" , 2 , collected = len ( all_ids ))
87135
88136 predecessors = [n for n in all_ids if n != target ]
89137 print (f"Collected { len (all_ids )} tests ({ len (predecessors )} candidates)." , file = sys .stderr )
90138
91139 # Sanity check 1: target passes alone.
92140 print ("Sanity check: target alone..." , file = sys .stderr )
93- solo = run_ordered ( workdir , [target ], extra )
141+ solo = _run ( [target ])
94142 if target_failed (solo , target ):
95143 print (
96144 f"ERROR: target fails alone — this isn't an ordering issue.\n "
97145 f"Outcome: { solo .outcomes .get (target )} \n "
98146 f"pytest exit code: { solo .exit_code } " ,
99147 file = sys .stderr ,
100148 )
101- return 3
149+ return _report ( "fails_alone" , 3 , target_outcome = solo . outcomes . get ( target ))
102150 if solo .outcomes .get (target ) != "PASSED" :
103151 print (
104152 f"ERROR: target was not run when invoked alone (outcome={ solo .outcomes .get (target )!r} ).\n "
105153 f"pytest exit code: { solo .exit_code } \n "
106154 f"--- pytest stdout ---\n { solo .stdout } " ,
107155 file = sys .stderr ,
108156 )
109- return 3
157+ return _report ( "target_not_run_alone" , 3 , target_outcome = solo . outcomes . get ( target ))
110158 print (" OK (passes alone)" , file = sys .stderr )
111159
112160 # Sanity check 2: target fails when run after everything else.
113161 print ("Sanity check: target after full suite..." , file = sys .stderr )
114- full = run_ordered ( workdir , [* predecessors , target ], extra )
162+ full = _run ( [* predecessors , target ])
115163 if not target_failed (full , target ):
116164 print (
117165 f"WARNING: target did not fail in the full ordered run "
118166 f"(outcome={ full .outcomes .get (target )!r} ). Pollution may be "
119167 f"order- or randomness-dependent; flake-bisect cannot help here." ,
120168 file = sys .stderr ,
121169 )
122- return 4
170+ return _report ( "no_pollution_reproduced" , 4 , target_outcome = full . outcomes . get ( target ))
123171 print (f" OK (target outcome: { full .outcomes [target ]} )" , file = sys .stderr )
124172
125173 # Bisect.
126174 iteration = {"i" : 0 }
127175
128176 def predicate (preds : list [str ]) -> bool :
129177 iteration ["i" ] += 1
130- res = run_ordered ( workdir , [* preds , target ], extra )
178+ res = _run ( [* preds , target ])
131179 failed = target_failed (res , target )
132180 if args .verbose :
133181 print (
@@ -142,18 +190,66 @@ def predicate(preds: list[str]) -> bool:
142190 culprits = ddmin (predecessors , predicate , max_calls = args .max_runs )
143191 except RuntimeError as e :
144192 print (f"ERROR: { e } " , file = sys .stderr )
145- return 5
193+ return _report ("max_runs_exhausted" , 5 , bisect_iterations = iteration ["i" ])
194+
195+ # Confirmation: (1) the minimal set reproduces the failure *standalone*, and
196+ # (2) removing it from the full suite lets the target pass. If the target
197+ # still fails without the culprits, there is at least one more independent
198+ # polluter that this run did not surface.
199+ reproduces_standalone : bool | None = None
200+ target_clean_without_culprits : bool | None = None
201+ if not args .no_confirm :
202+ print ("Confirming..." , file = sys .stderr )
203+ standalone = _run ([* culprits , target ])
204+ reproduces_standalone = target_failed (standalone , target )
205+ remaining = [n for n in predecessors if n not in set (culprits )]
206+ without = _run ([* remaining , target ])
207+ target_clean_without_culprits = not target_failed (without , target )
208+ print (
209+ f" minimal set reproduces standalone: "
210+ f"{ 'yes' if reproduces_standalone else 'NO' } " ,
211+ file = sys .stderr ,
212+ )
213+ print (
214+ f" target clean once culprits removed: "
215+ f"{ 'yes' if target_clean_without_culprits else 'NO (more polluters remain)' } " ,
216+ file = sys .stderr ,
217+ )
146218
147219 print ("" )
148220 print (f"Minimal poisoning set ({ len (culprits )} test{ 's' if len (culprits ) != 1 else '' } ):" )
149221 for nid in culprits :
150222 print (f" { nid } " )
151223 print ("" )
152224 print ("Reproduce locally:" )
153- print (" pytest " + " " .join ([* culprits , target ]))
225+ reproduce_cmd = "pytest " + " " .join ([* culprits , target ])
226+ print (" " + reproduce_cmd )
227+ if target_clean_without_culprits is False :
228+ print ("" )
229+ print (
230+ "NOTE: the target still fails with these tests removed — there is at\n "
231+ " least one more independent polluter. Re-run flake-bisect after\n "
232+ " fixing this set to find the next one."
233+ )
154234 print ("" )
155- print (f"pytest invocations during bisect: { iteration ['i' ]} (cap: { args .max_runs } )" , file = sys .stderr )
156- return 0
235+ print (
236+ f"pytest invocations: { runs ['total' ]} "
237+ f"(bisect iterations: { iteration ['i' ]} , cap: { args .max_runs } )" ,
238+ file = sys .stderr ,
239+ )
240+
241+ return _report (
242+ "poisoned" ,
243+ 0 ,
244+ culprits = culprits ,
245+ reproduce_command = reproduce_cmd ,
246+ bisect_iterations = iteration ["i" ],
247+ confirmation = {
248+ "reproduces_standalone" : reproduces_standalone ,
249+ "target_clean_without_culprits" : target_clean_without_culprits ,
250+ "additional_polluters_possible" : target_clean_without_culprits is False ,
251+ },
252+ )
157253
158254
159255if __name__ == "__main__" :
0 commit comments