claude-dev-env 1.44.0 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CLAUDE.md +9 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
  7. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
  8. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
  9. package/agents/clean-coder.md +7 -1
  10. package/agents/code-quality-agent.md +8 -5
  11. package/hooks/blocking/code_rules_enforcer.py +1562 -37
  12. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
  13. package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
  14. package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
  15. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
  16. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
  17. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
  18. package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
  19. package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
  20. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
  21. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
  22. package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
  23. package/hooks/hooks.json +10 -0
  24. package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
  26. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
  27. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
  28. package/package.json +1 -1
  29. package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
  30. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
  31. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
  32. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
  33. package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
  34. package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
  35. package/skills/bugteam/PROMPTS.md +48 -12
  36. package/skills/bugteam/reference/team-setup.md +4 -2
  37. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
  38. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
  39. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
  40. package/skills/pr-converge/SKILL.md +5 -0
  41. package/skills/pr-converge/reference/per-tick.md +14 -5
  42. package/skills/pr-converge/reference/state-schema.md +7 -3
  43. package/skills/pr-converge/scripts/check_convergence.py +27 -1
  44. package/skills/pr-converge/scripts/test_check_convergence.py +28 -0
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import subprocess
4
5
  import sys
5
6
  import unittest.mock
@@ -32,6 +33,11 @@ def initialize_git_repository(repository_root: Path) -> None:
32
33
  run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
33
34
  run_git_in_repository(repository_root, "config", "user.name", "Test")
34
35
  run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
36
+ disabled_hooks_directory = repository_root / "disabled-git-hooks"
37
+ disabled_hooks_directory.mkdir()
38
+ run_git_in_repository(
39
+ repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
40
+ )
35
41
 
36
42
 
37
43
  def commit_all_files(repository_root: Path, commit_message: str) -> None:
@@ -177,10 +183,10 @@ def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
177
183
  temporary_git_repository: Path,
178
184
  monkeypatch: pytest.MonkeyPatch,
179
185
  ) -> None:
180
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
186
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
181
187
  commit_all_files(temporary_git_repository, "initial")
182
188
  staged_content_with_banned_identifier = (
183
- "first_value = 1\n"
189
+ "first_count = 1\n"
184
190
  "def compute_total(operand):\n"
185
191
  " result = operand + 1\n"
186
192
  " return result\n"
@@ -201,10 +207,10 @@ def test_main_staged_mode_passes_when_no_staged_violations(
201
207
  temporary_git_repository: Path,
202
208
  monkeypatch: pytest.MonkeyPatch,
203
209
  ) -> None:
204
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
210
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
205
211
  commit_all_files(temporary_git_repository, "initial")
206
212
  write_file(
207
- temporary_git_repository / "module.py", "first_value = 1\nsecond_value = 2\n"
213
+ temporary_git_repository / "module.py", "first_count = 1\nsecond_count = 2\n"
208
214
  )
209
215
  stage_file(temporary_git_repository, "module.py")
210
216
 
@@ -218,7 +224,7 @@ def test_main_staged_mode_exits_zero_when_nothing_staged(
218
224
  temporary_git_repository: Path,
219
225
  monkeypatch: pytest.MonkeyPatch,
220
226
  ) -> None:
221
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
227
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
222
228
  commit_all_files(temporary_git_repository, "initial")
223
229
 
224
230
  monkeypatch.chdir(temporary_git_repository)
@@ -304,6 +310,27 @@ def test_whole_file_line_set_raises_system_exit_on_oserror(
304
310
  assert "denied" in captured.err or "PermissionError" in captured.err
305
311
 
306
312
 
313
+ def test_whole_file_line_set_raises_system_exit_on_non_utf8_file(
314
+ tmp_path: Path,
315
+ capsys: pytest.CaptureFixture[str],
316
+ ) -> None:
317
+ """A genuine non-UTF-8 file must fail closed as SystemExit, never crash.
318
+
319
+ ``read_text(encoding="utf-8")`` on undecodable bytes raises
320
+ UnicodeDecodeError, a ValueError subclass that ``OSError`` does not catch.
321
+ The fail-closed contract that holds for read failures holds equally here:
322
+ returning an empty set would route every violation to the advisory bucket,
323
+ so an undecodable file must propagate as SystemExit rather than escape as
324
+ an unhandled UnicodeDecodeError.
325
+ """
326
+ non_utf8_path = tmp_path / "garbled.py"
327
+ non_utf8_path.write_bytes(b"\xff\xfe\x00bad")
328
+ with pytest.raises(SystemExit):
329
+ gate_module.whole_file_line_set(non_utf8_path)
330
+ captured = capsys.readouterr()
331
+ assert str(non_utf8_path) in captured.err
332
+
333
+
307
334
  def test_check_database_column_string_magic_signals_cap_exit(
308
335
  capsys: pytest.CaptureFixture[str],
309
336
  ) -> None:
@@ -329,10 +356,10 @@ def test_check_database_column_string_magic_signals_cap_exit(
329
356
  assert "cap reached" in captured.err.lower()
330
357
 
331
358
 
332
- def test_check_wrapper_plumb_through_signals_cap_exit(
333
- capsys: pytest.CaptureFixture[str],
334
- ) -> None:
335
- """check_wrapper_plumb_through must signal when MAXIMUM_ISSUES_TO_REPORT trims."""
359
+ def test_check_wrapper_plumb_through_caps_findings_at_max_per_check() -> None:
360
+ """check_wrapper_plumb_through stops emitting at MAX_VIOLATIONS_PER_CHECK
361
+ findings the cap bounds the blocking payload silently, matching the
362
+ _shared gate copy."""
336
363
  delegate_definition = (
337
364
  "def delegate(*, optional_one=1, optional_two=2, optional_three=3,"
338
365
  " optional_four=4): return 0\n"
@@ -346,9 +373,61 @@ def test_check_wrapper_plumb_through_signals_cap_exit(
346
373
  source_with_many_wrappers,
347
374
  "production/wrappers.py",
348
375
  )
349
- assert len(issues) == 3
350
- captured = capsys.readouterr()
351
- assert "cap reached" in captured.err.lower()
376
+ assert len(issues) == gate_module.MAX_VIOLATIONS_PER_CHECK
377
+
378
+
379
+ def _bugteam_banned_noun_parameter_issues() -> list[str]:
380
+ validate_content = gate_module.load_validate_content()
381
+ source = (
382
+ "def aggregate(canned_results: int) -> int:\n"
383
+ " doubled = canned_results * 2\n"
384
+ " return doubled\n"
385
+ )
386
+ issues = validate_content(source, "src/module.py", "")
387
+ return [each_issue for each_issue in issues if "banned noun" in each_issue]
388
+
389
+
390
+ def test_bugteam_split_violations_blocks_banned_noun_when_binding_line_is_added() -> None:
391
+ """The bugteam gate reconstructs a banned-noun binding's one-line span the
392
+ same way the _shared gate does: the violation is blocking when its own
393
+ binding line is among the added lines."""
394
+ banned_noun_issues = _bugteam_banned_noun_parameter_issues()
395
+ assert banned_noun_issues, "expected a banned-noun parameter issue"
396
+ parameter_binding_line = 1
397
+ blocking, advisory = gate_module.split_violations_by_scope(
398
+ banned_noun_issues,
399
+ {parameter_binding_line},
400
+ )
401
+ assert blocking == banned_noun_issues
402
+ assert advisory == []
403
+
404
+
405
+ def test_bugteam_split_violations_advises_banned_noun_when_binding_line_untouched() -> None:
406
+ """A banned-noun binding whose own line is untouched is advisory at the
407
+ bugteam gate, so editing an unrelated body line does not pull a pre-existing
408
+ binding into scope."""
409
+ banned_noun_issues = _bugteam_banned_noun_parameter_issues()
410
+ assert banned_noun_issues, "expected a banned-noun parameter issue"
411
+ unrelated_body_line = 2
412
+ blocking, advisory = gate_module.split_violations_by_scope(
413
+ banned_noun_issues,
414
+ {unrelated_body_line},
415
+ )
416
+ assert advisory == banned_noun_issues
417
+ assert blocking == []
418
+
419
+
420
+ def test_bugteam_banned_noun_span_range_covers_only_the_binding_line() -> None:
421
+ """The reconstructed span is the binding line alone — one line, never the
422
+ enclosing function span. A parameter declared on a ``def`` line yields a
423
+ range covering only that line, so an unrelated body edit cannot pull the
424
+ pre-existing binding into scope."""
425
+ banned_noun_issues = _bugteam_banned_noun_parameter_issues()
426
+ assert banned_noun_issues, "expected a banned-noun parameter issue"
427
+ parameter_binding_line = 1
428
+ span = gate_module.banned_noun_span_range(banned_noun_issues[0])
429
+ assert span == range(parameter_binding_line, parameter_binding_line + 1)
430
+ assert len(span) == 1
352
431
 
353
432
 
354
433
  def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
@@ -378,6 +457,31 @@ def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
378
457
  assert "skip unreadable" in captured.err
379
458
 
380
459
 
460
+ def test_run_gate_skips_non_utf8_file_without_crashing(
461
+ tmp_path: Path,
462
+ capsys: pytest.CaptureFixture[str],
463
+ ) -> None:
464
+ """A non-UTF-8 code file is skipped, not crashed on, and forces a non-zero exit."""
465
+ non_utf8_file = tmp_path / "garbled.py"
466
+ non_utf8_file.write_bytes(b"\xff\xfe\x00bad bytes")
467
+
468
+ def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
469
+ return []
470
+
471
+ exit_code = gate_module.run_gate(
472
+ fake_validate,
473
+ [non_utf8_file],
474
+ tmp_path,
475
+ all_added_lines_map=None,
476
+ )
477
+
478
+ captured = capsys.readouterr()
479
+ assert exit_code != 0, (
480
+ "A file skipped for non-UTF-8 content must produce a non-zero gate exit"
481
+ )
482
+ assert "skip unreadable" in captured.err
483
+
484
+
381
485
  def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
382
486
  temporary_git_repository: Path,
383
487
  monkeypatch: pytest.MonkeyPatch,
@@ -398,3 +502,489 @@ def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empt
398
502
  )
399
503
 
400
504
  assert added_line_numbers == set()
505
+
506
+
507
+ def _build_function_module(
508
+ function_name: str, body_line_count: int, leading_lines: int
509
+ ) -> str:
510
+ preamble = "".join("anchor_name\n" for _ in range(leading_lines))
511
+ body = "\n".join(" keep_alive_name" for _ in range(body_line_count))
512
+ return f"{preamble}def {function_name}() -> None:\n{body}\n"
513
+
514
+
515
+ def test_split_violations_blocks_function_length_when_span_intersects_added_lines() -> None:
516
+ """A function-length issue whose declared span overlaps the diff's added
517
+ lines is blocking — the body grew, which is the regression intent."""
518
+ validate_content = gate_module.load_validate_content()
519
+ long_function = _build_function_module(
520
+ "oversized", body_line_count=70, leading_lines=3
521
+ )
522
+ issues = validate_content(long_function, "src/long_module.py", "")
523
+ function_length_issues = [
524
+ each_issue for each_issue in issues if "blocking threshold" in each_issue
525
+ ]
526
+ assert function_length_issues, f"expected a function-length issue, got {issues!r}"
527
+ span_def_line = 4
528
+ inside_span_line = span_def_line + 10
529
+ blocking, advisory = gate_module.split_violations_by_scope(
530
+ function_length_issues,
531
+ all_added_line_numbers={inside_span_line},
532
+ )
533
+ assert blocking == function_length_issues
534
+ assert advisory == []
535
+
536
+
537
+ def test_split_violations_advises_function_length_when_span_misses_added_lines() -> None:
538
+ """A function-length issue for an untouched pre-existing function — whose
539
+ declared span does not overlap any added line — is advisory, not blocking.
540
+ Prevents the over-block regression where every pre-existing long function
541
+ in a touched file was forced into the blocking payload."""
542
+ validate_content = gate_module.load_validate_content()
543
+ long_function = _build_function_module(
544
+ "oversized", body_line_count=70, leading_lines=3
545
+ )
546
+ issues = validate_content(long_function, "src/long_module.py", "")
547
+ function_length_issues = [
548
+ each_issue for each_issue in issues if "blocking threshold" in each_issue
549
+ ]
550
+ assert function_length_issues, f"expected a function-length issue, got {issues!r}"
551
+ line_far_outside_span = 5000
552
+ blocking, advisory = gate_module.split_violations_by_scope(
553
+ function_length_issues,
554
+ all_added_line_numbers={line_far_outside_span},
555
+ )
556
+ assert advisory == function_length_issues
557
+ assert blocking == []
558
+
559
+
560
+ def _isolation_issues_for_home_probe_test() -> list[str]:
561
+ validate_content = gate_module.load_validate_content()
562
+ header = "from pathlib import Path\n"
563
+ test_body = (
564
+ "def test_reads_home() -> None:\n"
565
+ " target_path = Path.home()\n"
566
+ " assert target_path\n"
567
+ )
568
+ issues = validate_content(header + test_body, "src/test_module.py", "")
569
+ return [each_issue for each_issue in issues if "probes" in each_issue]
570
+
571
+
572
+ def test_split_violations_blocks_isolation_when_function_span_intersects_added_lines() -> None:
573
+ """An isolation issue whose enclosing test-function span overlaps the diff's
574
+ added lines is blocking — a signature-line change that un-isolates an
575
+ unchanged-body probe must block, matching the enforcer's terminal path."""
576
+ isolation_issues = _isolation_issues_for_home_probe_test()
577
+ assert isolation_issues, "expected an isolation issue from the HOME probe test"
578
+ signature_line = 2
579
+ blocking, advisory = gate_module.split_violations_by_scope(
580
+ isolation_issues,
581
+ all_added_line_numbers={signature_line},
582
+ )
583
+ assert blocking == isolation_issues
584
+ assert advisory == []
585
+
586
+
587
+ def test_split_violations_advises_isolation_when_function_span_misses_added_lines() -> None:
588
+ """An isolation issue for an untouched pre-existing probe — whose enclosing
589
+ test-function span does not overlap any added line — is advisory, not
590
+ blocking, mirroring the function-length scope contract."""
591
+ isolation_issues = _isolation_issues_for_home_probe_test()
592
+ assert isolation_issues, "expected an isolation issue from the HOME probe test"
593
+ line_far_outside_span = 5000
594
+ blocking, advisory = gate_module.split_violations_by_scope(
595
+ isolation_issues,
596
+ all_added_line_numbers={line_far_outside_span},
597
+ )
598
+ assert advisory == isolation_issues
599
+ assert blocking == []
600
+
601
+
602
+ def _oversized_function_text(function_name: str) -> str:
603
+ body = "\n".join(" keep_alive_name" for _ in range(70))
604
+ return f"def {function_name}() -> None:\n{body}\n"
605
+
606
+
607
+ def _short_function_text(function_name: str) -> str:
608
+ return f"def {function_name}() -> None:\n keep_alive_name\n"
609
+
610
+
611
+ def test_main_blocks_sixth_long_function_on_added_lines_past_document_order(
612
+ temporary_git_repository: Path,
613
+ monkeypatch: pytest.MonkeyPatch,
614
+ ) -> None:
615
+ """bugbot-2: with five pre-existing untouched long functions ahead of it in
616
+ document order, growing the sixth function past the threshold on staged
617
+ lines must still block at the bugteam gate. The gate scopes by added lines,
618
+ so the in-scope sixth violation blocks regardless of how many untouched
619
+ ones precede it."""
620
+ leading_long_functions = "".join(
621
+ _oversized_function_text(f"leading_long_{each_index}")
622
+ for each_index in range(5)
623
+ )
624
+ baseline = leading_long_functions + _short_function_text("target_function")
625
+ write_file(temporary_git_repository / "module.py", baseline)
626
+ commit_all_files(temporary_git_repository, "five long functions plus a short sixth")
627
+
628
+ grown = leading_long_functions + _oversized_function_text("target_function")
629
+ write_file(temporary_git_repository / "module.py", grown)
630
+ stage_file(temporary_git_repository, "module.py")
631
+
632
+ monkeypatch.chdir(temporary_git_repository)
633
+ exit_code = gate_module.main(["--staged"])
634
+
635
+ assert exit_code == 1, (
636
+ "the sixth long function — the only one on staged lines — must block "
637
+ "even though five untouched long functions precede it in document order"
638
+ )
639
+
640
+
641
+ def _home_probe_test_text(test_name: str) -> str:
642
+ return (
643
+ f"def {test_name}() -> None:\n"
644
+ " target_path = Path.home()\n"
645
+ " assert target_path\n"
646
+ )
647
+
648
+
649
+ def _clean_test_text(test_name: str) -> str:
650
+ return f"def {test_name}() -> None:\n assert 1 + 1 == 2\n"
651
+
652
+
653
+ def test_main_blocks_sixth_isolation_probe_on_added_lines_past_document_order(
654
+ temporary_git_repository: Path,
655
+ monkeypatch: pytest.MonkeyPatch,
656
+ ) -> None:
657
+ """bugbot-2 mirror: with five pre-existing untouched HOME probes ahead of it
658
+ in document order, adding a HOME probe to the sixth test on staged lines
659
+ must still block at the bugteam gate. The gate scopes by added lines, so the
660
+ in-scope sixth probe blocks regardless of how many untouched ones precede
661
+ it."""
662
+ header = "from pathlib import Path\n"
663
+ leading_probe_tests = "".join(
664
+ _home_probe_test_text(f"test_leading_probe_{each_index}")
665
+ for each_index in range(5)
666
+ )
667
+ baseline = header + leading_probe_tests + _clean_test_text("test_target_probe")
668
+ write_file(temporary_git_repository / "test_module.py", baseline)
669
+ commit_all_files(temporary_git_repository, "five probe tests plus a clean sixth")
670
+
671
+ grown = header + leading_probe_tests + _home_probe_test_text("test_target_probe")
672
+ write_file(temporary_git_repository / "test_module.py", grown)
673
+ stage_file(temporary_git_repository, "test_module.py")
674
+
675
+ monkeypatch.chdir(temporary_git_repository)
676
+ exit_code = gate_module.main(["--staged"])
677
+
678
+ assert exit_code == 1, (
679
+ "the sixth HOME probe — the only one on staged lines — must block even "
680
+ "though five untouched probes precede it in document order"
681
+ )
682
+
683
+
684
+ def _banned_noun_function_text(index: int) -> str:
685
+ return (
686
+ f"def leading_{index}(canned_results: int) -> int:\n"
687
+ f" return canned_results\n"
688
+ )
689
+
690
+
691
+ def test_main_blocks_banned_noun_on_added_lines_past_document_order(
692
+ temporary_git_repository: Path,
693
+ monkeypatch: pytest.MonkeyPatch,
694
+ ) -> None:
695
+ """loop7-P1: with three pre-existing untouched banned-noun identifiers ahead
696
+ of it in document order, introducing a fourth banned-noun on a staged line
697
+ must still block at the bugteam gate. The gate scopes by added lines, so the
698
+ in-scope identifier blocks regardless of how many untouched ones precede
699
+ it."""
700
+ leading_count = 3
701
+ leading_functions = "".join(
702
+ _banned_noun_function_text(each_index) for each_index in range(leading_count)
703
+ )
704
+ baseline = leading_functions + "def placeholder() -> int:\n return 0\n"
705
+ write_file(temporary_git_repository / "module.py", baseline)
706
+ commit_all_files(temporary_git_repository, "three banned nouns plus a clean function")
707
+
708
+ grown = leading_functions + "def aggregate(holiday_result: int) -> int:\n return holiday_result\n"
709
+ write_file(temporary_git_repository / "module.py", grown)
710
+ stage_file(temporary_git_repository, "module.py")
711
+
712
+ monkeypatch.chdir(temporary_git_repository)
713
+ exit_code = gate_module.main(["--staged"])
714
+
715
+ assert exit_code == 1, (
716
+ "the fourth banned-noun identifier — the only one on staged lines — must "
717
+ "block even though three untouched ones precede it in document order"
718
+ )
719
+
720
+
721
+ def test_report_partitioned_violations_returns_zero_when_clean(tmp_path: Path) -> None:
722
+ """No blocking violations and no skipped files yields a zero exit code."""
723
+ exit_code = gate_module._report_partitioned_violations(
724
+ blocking_by_file={},
725
+ advisory_by_file={tmp_path / "a.py": ["Line 1: advisory only"]},
726
+ repository_root=tmp_path,
727
+ is_whole_file_scope=False,
728
+ skipped_unreadable_count=0,
729
+ )
730
+ assert exit_code == 0
731
+
732
+
733
+ def test_report_partitioned_violations_returns_one_on_blocking(tmp_path: Path) -> None:
734
+ """A blocking violation yields a non-zero exit code."""
735
+ exit_code = gate_module._report_partitioned_violations(
736
+ blocking_by_file={tmp_path / "a.py": ["Line 1: blocking violation"]},
737
+ advisory_by_file={},
738
+ repository_root=tmp_path,
739
+ is_whole_file_scope=False,
740
+ skipped_unreadable_count=0,
741
+ )
742
+ assert exit_code == 1
743
+
744
+
745
+ def test_report_partitioned_violations_returns_one_when_file_skipped(tmp_path: Path) -> None:
746
+ """A skipped unreadable file forces a non-zero exit even with no blocking
747
+ violations, because the gate cannot vouch for the file it could not read."""
748
+ exit_code = gate_module._report_partitioned_violations(
749
+ blocking_by_file={},
750
+ advisory_by_file={},
751
+ repository_root=tmp_path,
752
+ is_whole_file_scope=False,
753
+ skipped_unreadable_count=1,
754
+ )
755
+ assert exit_code == 1
756
+
757
+
758
+ def test_check_wrapper_plumb_through_skips_class_methods_calling_module_delegate() -> None:
759
+ """A class method calling a module-level delegate is not a wrapper; its
760
+ signature is unrelated to the delegate's keyword surface, so it must not be
761
+ flagged — matching the _shared gate copy."""
762
+ source = (
763
+ "def fetch(target, *, retries=3):\n"
764
+ " return target\n"
765
+ "\n"
766
+ "class MyService:\n"
767
+ " def public_method(self, target):\n"
768
+ " return fetch(target)\n"
769
+ )
770
+ issues = gate_module.check_wrapper_plumb_through(source, "module.py")
771
+ assert issues == [], (
772
+ f"class methods must not be treated as module-level wrappers; got {issues!r}"
773
+ )
774
+
775
+
776
+ def test_check_wrapper_plumb_through_flags_name_call_dropping_kwarg() -> None:
777
+ """A bare-name call (``delegate(value)``) to a same-file delegate that
778
+ exposes an optional kwarg the public wrapper omits must be flagged — the
779
+ bugteam copy must handle ``ast.Name`` targets, not only ``ast.Attribute``."""
780
+ source = (
781
+ "def delegate(value, *, retries=3):\n"
782
+ " return value\n"
783
+ "\n"
784
+ "def public_wrapper(value):\n"
785
+ " return delegate(value)\n"
786
+ )
787
+ issues = gate_module.check_wrapper_plumb_through(source, "module.py")
788
+ assert any("retries" in each_issue for each_issue in issues), (
789
+ f"a bare-name delegate call dropping an optional kwarg must flag; got {issues!r}"
790
+ )
791
+
792
+
793
+ def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
794
+ """A callee nested as an argument (``delegate(helper(x))``) is not a
795
+ separate call site; only the enclosing call is inspected, matching the
796
+ _shared gate copy."""
797
+ source = (
798
+ "def delegate(value, *, retries=3):\n"
799
+ " return value\n"
800
+ "\n"
801
+ "def helper(value):\n"
802
+ " return value\n"
803
+ "\n"
804
+ "def public_caller(value):\n"
805
+ " return delegate(helper(value))\n"
806
+ )
807
+ issues = gate_module.check_wrapper_plumb_through(source, "module.py")
808
+ assert all("helper" not in each_issue for each_issue in issues), (
809
+ f"nested-argument callee must not be a separate call site; got {issues!r}"
810
+ )
811
+
812
+
813
+ def test_main_staged_mode_blocks_newly_staged_inline_comment(
814
+ temporary_git_repository: Path,
815
+ monkeypatch: pytest.MonkeyPatch,
816
+ ) -> None:
817
+ """A NEWLY STAGED inline comment must be detected as a new comment in
818
+ ``--staged`` mode. This proves the gate passes the HEAD-committed content as
819
+ ``old_content`` to the comparison validators (the prior base), not the
820
+ current working-tree content. When the current file content is reused as
821
+ ``old_content``, ``check_comment_changes`` sees identical old/new text and
822
+ misses the staged comment entirely, so the gate exits 0 — the regression."""
823
+ write_file(
824
+ temporary_git_repository / "module.py",
825
+ 'def describe_state() -> str:\n return "ready"\n',
826
+ )
827
+ commit_all_files(temporary_git_repository, "initial without comment")
828
+ staged_content_with_new_comment = (
829
+ "def describe_state() -> str:\n"
830
+ ' label = "ready" # newly staged inline comment\n'
831
+ " return label\n"
832
+ )
833
+ write_file(
834
+ temporary_git_repository / "module.py",
835
+ staged_content_with_new_comment,
836
+ )
837
+ stage_file(temporary_git_repository, "module.py")
838
+
839
+ monkeypatch.chdir(temporary_git_repository)
840
+ exit_code = gate_module.main(["--staged"])
841
+
842
+ assert exit_code == 1, (
843
+ "a newly staged inline comment must block; if it does not, the gate is "
844
+ "passing the current file content as old_content instead of the "
845
+ "HEAD-committed prior base, so check_comment_changes sees identical "
846
+ "old/new text and misses the staged comment"
847
+ )
848
+
849
+
850
+ def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
851
+ """check_wrapper_plumb_through must stay under the enforcer's function-length
852
+ blocking threshold so its signature-index, class-method-id, and per-wrapper
853
+ finding logic live in extracted helpers, matching the _shared gate copy."""
854
+ declared_line_count = len(
855
+ inspect.getsource(gate_module.check_wrapper_plumb_through).splitlines()
856
+ )
857
+ blocking_threshold = 60
858
+ assert declared_line_count < blocking_threshold, (
859
+ f"check_wrapper_plumb_through is {declared_line_count} lines; extract "
860
+ "helpers to keep it under the function-length blocking threshold"
861
+ )
862
+
863
+
864
+ def test_read_prior_committed_content_returns_head_content_for_tracked_path(
865
+ temporary_git_repository: Path,
866
+ ) -> None:
867
+ """A tracked path returns its HEAD-committed content, not the working copy."""
868
+ committed_text = "alpha = 1\nbeta = 2\n"
869
+ write_file(temporary_git_repository / "tracked.py", committed_text)
870
+ commit_all_files(temporary_git_repository, "commit tracked file")
871
+ write_file(
872
+ temporary_git_repository / "tracked.py",
873
+ committed_text + "gamma = 3\n",
874
+ )
875
+
876
+ prior_content = gate_module.read_prior_committed_content(
877
+ temporary_git_repository.resolve(), "tracked.py"
878
+ )
879
+
880
+ assert prior_content == committed_text
881
+
882
+
883
+ def test_read_prior_committed_content_returns_empty_for_untracked_path(
884
+ temporary_git_repository: Path,
885
+ ) -> None:
886
+ """An untracked path yields an empty string because git show returns non-zero."""
887
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
888
+ commit_all_files(temporary_git_repository, "anchor commit")
889
+
890
+ prior_content = gate_module.read_prior_committed_content(
891
+ temporary_git_repository.resolve(), "never_committed.py"
892
+ )
893
+
894
+ assert prior_content == ""
895
+
896
+
897
+ def test_main_staged_mode_validates_staged_blob_not_working_tree(
898
+ temporary_git_repository: Path,
899
+ monkeypatch: pytest.MonkeyPatch,
900
+ ) -> None:
901
+ """Staged mode validates the staged blob, not the working tree.
902
+
903
+ A blocking violation lives in the staged blob, but the working tree has
904
+ been edited afterward to remove it. The gate must still block because it
905
+ scopes added lines from the staged index and must read its content from
906
+ the same staged source rather than the diverged working tree.
907
+ """
908
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
909
+ commit_all_files(temporary_git_repository, "initial")
910
+ staged_content_with_banned_identifier = (
911
+ "first_count = 1\n"
912
+ "def compute_total(operand):\n"
913
+ " result = operand + 1\n"
914
+ " return result\n"
915
+ )
916
+ write_file(
917
+ temporary_git_repository / "module.py",
918
+ staged_content_with_banned_identifier,
919
+ )
920
+ stage_file(temporary_git_repository, "module.py")
921
+ clean_working_tree_content = (
922
+ "first_count = 1\n"
923
+ "def compute_total(operand: int) -> int:\n"
924
+ " return operand + 1\n"
925
+ )
926
+ write_file(
927
+ temporary_git_repository / "module.py",
928
+ clean_working_tree_content,
929
+ )
930
+
931
+ monkeypatch.chdir(temporary_git_repository)
932
+ exit_code = gate_module.main(["--staged"])
933
+
934
+ assert exit_code == 1, (
935
+ "the staged blob carries a blocking violation; the gate must block "
936
+ "even though the working tree was edited clean afterward"
937
+ )
938
+
939
+
940
+ def test_main_staged_mode_blocks_when_staged_file_absent_from_working_tree(
941
+ temporary_git_repository: Path,
942
+ monkeypatch: pytest.MonkeyPatch,
943
+ ) -> None:
944
+ """A staged blocking violation must block even when the working tree file
945
+ is gone. Staging a violating file and then deleting it from the working
946
+ tree leaves the violation only in the staged blob; the gate must validate
947
+ that blob rather than skip the path for failing a working-tree existence
948
+ check."""
949
+ write_file(temporary_git_repository / "baseline.py", "first_count = 1\n")
950
+ commit_all_files(temporary_git_repository, "initial")
951
+ staged_content_with_banned_identifier = (
952
+ "def compute_total(operand):\n"
953
+ " result = operand + 1\n"
954
+ " return result\n"
955
+ )
956
+ write_file(
957
+ temporary_git_repository / "module.py",
958
+ staged_content_with_banned_identifier,
959
+ )
960
+ stage_file(temporary_git_repository, "module.py")
961
+ (temporary_git_repository / "module.py").unlink()
962
+
963
+ monkeypatch.chdir(temporary_git_repository)
964
+ exit_code = gate_module.main(["--staged"])
965
+
966
+ assert exit_code == 1, (
967
+ "the staged blob carries a blocking violation; the gate must block "
968
+ "even though the file was deleted from the working tree after staging"
969
+ )
970
+
971
+
972
+ def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
973
+ temporary_git_repository: Path,
974
+ monkeypatch: pytest.MonkeyPatch,
975
+ ) -> None:
976
+ """A staged deletion is not in the index, so its staged blob cannot be read.
977
+ The gate must skip such a path cleanly rather than counting it as an
978
+ unreadable file and failing closed. With no other staged violation, the
979
+ gate must exit zero."""
980
+ write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
981
+ commit_all_files(temporary_git_repository, "initial")
982
+ run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
983
+
984
+ monkeypatch.chdir(temporary_git_repository)
985
+ exit_code = gate_module.main(["--staged"])
986
+
987
+ assert exit_code == 0, (
988
+ "a staged deletion has no staged blob; the gate must skip it cleanly "
989
+ "rather than fail closed as if the file were unreadable"
990
+ )
@@ -125,6 +125,11 @@ no longer applies.
125
125
  - [ ] **Step 4: BUGBOT — fetch, decide, fix, reply, resolve**
126
126
  See: [`reference/per-tick.md` § Step 2 BUGBOT + Step 3](reference/per-tick.md)
127
127
 
128
+ - [ ] **Opt-out gate (runs first, every BUGBOT entry).**
129
+ `python "$HOME/.claude/_shared/pr-loop/scripts/reviews_disabled.py" --reviewer bugbot`
130
+ - [ ] Exit 0 (`CLAUDE_REVIEWS_DISABLED` lists `bugbot`) → set `bugbot_down = true`, `phase = BUGTEAM`, advance to Step 5 (bypass). Cursor Bugbot is skipped for the entire run.
131
+ - [ ] Exit 1 → continue below.
132
+
128
133
  Fetch bugbot reviews + inline comments on `current_head`.
129
134
 
130
135
  - [ ] **dirty** (findings on `current_head`) →