@@ -308,7 +308,7 @@ def test_input_text_is_passed_through(self):
308308 self .assertTrue (mock_run .call_args [1 ]["text" ])
309309
310310 def test_invalid_json_returns_none_with_raw_output (self ):
311- mock_result = MagicMock (returncode = 1 , stdout = "Commit rejected.\n " )
311+ mock_result = MagicMock (returncode = 1 , stdout = "Commit rejected.\n " , stderr = "" )
312312 with patch ("main.subprocess.run" , return_value = mock_result ):
313313 rc , data , raw = main .run_check_json (["--branch" ])
314314 self .assertEqual (rc , 1 )
@@ -352,7 +352,7 @@ def test_parses_checks_into_scope(self):
352352 self .assertEqual (scope .failures [0 ]["rule_id" ], "CC001" )
353353
354354 def test_invalid_json_falls_back_to_raw_text (self ):
355- mock_result = MagicMock (returncode = 1 , stdout = "unexpected output" )
355+ mock_result = MagicMock (returncode = 1 , stdout = "unexpected output" , stderr = "" )
356356 with patch ("main.subprocess.run" , return_value = mock_result ):
357357 scope = main .check_scope ("Branch" , ["--branch" ])
358358 self .assertEqual (scope .label , "Branch" )
@@ -388,7 +388,7 @@ def test_failed_message_marks_scope_failed(self):
388388 self .assertEqual (scopes [0 ].sha , SHA_A )
389389
390390 def test_unparsable_output_still_names_the_commit (self ):
391- mock_result = MagicMock (returncode = 1 , stdout = "unexpected output" )
391+ mock_result = MagicMock (returncode = 1 , stdout = "unexpected output" , stderr = "" )
392392 with patch ("main.subprocess.run" , return_value = mock_result ):
393393 scopes = main .run_pr_message_checks ([(SHA_A , "bad commit" )])
394394 self .assertEqual (scopes [0 ].raw_text , "unexpected output" )
@@ -2841,3 +2841,78 @@ def test_failing_message(self):
28412841 "[CC001 message](https://commit-check.com/rules/#cc001)" ,
28422842 main .render_report ([scope ]),
28432843 )
2844+
2845+
2846+ class TestStderrIsNotPartOfTheJson (unittest .TestCase ):
2847+ """stdout is the JSON; stderr is what the CLI has to say to a person.
2848+
2849+ Merging the two fed notices to json.loads, and an unparsable response is
2850+ a failure as far as ScopeResult is concerned -- so a passing run went
2851+ red the moment the CLI gained something to say. It has several such
2852+ lines, printed under --format json as well: a parent config it could not
2853+ fetch, a flag whose every rule the config switched off, a dry run that
2854+ softened its own verdict.
2855+ """
2856+
2857+ #: What the CLI prints when --branch is asked for and the repository's
2858+ #: config has switched every branch rule off.
2859+ NOTICE = (
2860+ "⊘ --branch requested but no branch rules are configured "
2861+ "(conventional_branch = false and no require_rebase_target)\n "
2862+ )
2863+
2864+ def setUp (self ):
2865+ main ._RELAYED_NOTICES .clear ()
2866+
2867+ def test_the_two_streams_are_read_separately (self ):
2868+ mock_result = MagicMock (returncode = 0 , stdout = "{}" , stderr = "" )
2869+ with patch ("main.subprocess.run" , return_value = mock_result ) as mock_run :
2870+ main .run_check_json (["--branch" ])
2871+ self .assertIs (mock_run .call_args [1 ]["stderr" ], main .subprocess .PIPE )
2872+
2873+ def test_a_notice_does_not_break_the_json (self ):
2874+ mock_result = MagicMock (
2875+ returncode = 0 ,
2876+ stdout = json_output (make_check ("branch" )),
2877+ stderr = self .NOTICE ,
2878+ )
2879+ with patch ("main.subprocess.run" , return_value = mock_result ):
2880+ with patch .object (sys , "stderr" , io .StringIO ()):
2881+ rc , data , raw = main .run_check_json (["--branch" ])
2882+ self .assertEqual (rc , 0 )
2883+ self .assertIsNotNone (data )
2884+ self .assertEqual (data ["status" ], "pass" )
2885+ # And the scope built from it is a pass, not the "unparsable" fail.
2886+ self .assertEqual (main .check_scope ("Branch" , ["--branch" ]).label , "Branch" )
2887+
2888+ def test_a_notice_alone_does_not_fail_the_scope (self ):
2889+ mock_result = MagicMock (returncode = 0 , stdout = json_output (), stderr = self .NOTICE )
2890+ with patch ("main.subprocess.run" , return_value = mock_result ):
2891+ with patch .object (sys , "stderr" , io .StringIO ()):
2892+ scope = main .check_scope ("Branch" , ["--branch" ])
2893+ self .assertEqual (scope .status , "pass" )
2894+ self .assertEqual (scope .raw_text , "" )
2895+
2896+ def test_notices_reach_the_job_log_once (self ):
2897+ mock_result = MagicMock (
2898+ returncode = 0 , stdout = json_output (make_check ("branch" )), stderr = self .NOTICE
2899+ )
2900+ fake_err = io .StringIO ()
2901+ with patch ("main.subprocess.run" , return_value = mock_result ):
2902+ with patch .object (sys , "stderr" , fake_err ):
2903+ for _ in range (3 ):
2904+ main .run_check_json (["--branch" ])
2905+ printed = fake_err .getvalue ()
2906+ self .assertIn ("--branch requested but no branch rules are configured" , printed )
2907+ # Once, not once per scope and per commit in the pull request.
2908+ self .assertEqual (printed .count ("commit-check: ⊘" ), 1 )
2909+
2910+ def test_unparsable_output_keeps_both_streams (self ):
2911+ mock_result = MagicMock (
2912+ returncode = 2 , stdout = "" , stderr = "Error: cchk.toml: Expected ']'\n "
2913+ )
2914+ with patch ("main.subprocess.run" , return_value = mock_result ):
2915+ with patch .object (sys , "stderr" , io .StringIO ()):
2916+ scope = main .check_scope ("Branch" , ["--branch" ])
2917+ self .assertEqual (scope .status , "fail" )
2918+ self .assertIn ("Expected ']'" , scope .raw_text )
0 commit comments