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
@@ -49,6 +49,11 @@ def initialize_git_repository(repository_root: Path) -> None:
49
49
  run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
50
50
  run_git_in_repository(repository_root, "config", "user.name", "Test")
51
51
  run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
52
+ disabled_hooks_directory = repository_root / "disabled-git-hooks"
53
+ disabled_hooks_directory.mkdir()
54
+ run_git_in_repository(
55
+ repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
56
+ )
52
57
 
53
58
 
54
59
  def commit_all_files(repository_root: Path, commit_message: str) -> None:
@@ -242,10 +247,10 @@ def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
242
247
  temporary_git_repository: Path,
243
248
  monkeypatch: pytest.MonkeyPatch,
244
249
  ) -> None:
245
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
250
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
246
251
  commit_all_files(temporary_git_repository, "initial")
247
252
  staged_content_with_banned_identifier = (
248
- "first_value = 1\n"
253
+ "first_count = 1\n"
249
254
  "def compute_total(operand):\n"
250
255
  " result = operand + 1\n"
251
256
  " return result\n"
@@ -266,10 +271,10 @@ def test_main_staged_mode_passes_when_no_staged_violations(
266
271
  temporary_git_repository: Path,
267
272
  monkeypatch: pytest.MonkeyPatch,
268
273
  ) -> None:
269
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
274
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
270
275
  commit_all_files(temporary_git_repository, "initial")
271
276
  write_file(
272
- temporary_git_repository / "module.py", "first_value = 1\nsecond_value = 2\n"
277
+ temporary_git_repository / "module.py", "first_count = 1\nsecond_count = 2\n"
273
278
  )
274
279
  stage_file(temporary_git_repository, "module.py")
275
280
 
@@ -283,7 +288,7 @@ def test_main_staged_mode_exits_zero_when_nothing_staged(
283
288
  temporary_git_repository: Path,
284
289
  monkeypatch: pytest.MonkeyPatch,
285
290
  ) -> None:
286
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
291
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
287
292
  commit_all_files(temporary_git_repository, "initial")
288
293
 
289
294
  monkeypatch.chdir(temporary_git_repository)
@@ -451,12 +456,12 @@ def test_run_gate_detects_new_inline_comment_in_touched_file(
451
456
  """
452
457
  write_file(
453
458
  temporary_git_repository / "module.py",
454
- "first_value = 1\nsecond_value = 2\n",
459
+ "first_count = 1\nsecond_count = 2\n",
455
460
  )
456
461
  commit_all_files(temporary_git_repository, "initial")
457
462
  write_file(
458
463
  temporary_git_repository / "module.py",
459
- "first_value = 1\nsecond_value = 2 # added inline comment\n",
464
+ "first_count = 1\nsecond_count = 2 # added inline comment\n",
460
465
  )
461
466
  stage_file(temporary_git_repository, "module.py")
462
467
 
@@ -474,7 +479,7 @@ def test_run_gate_treats_new_files_prior_content_as_empty(
474
479
  commit_all_files(temporary_git_repository, "baseline")
475
480
  write_file(
476
481
  temporary_git_repository / "brand_new.py",
477
- "first_value = 1\nsecond_value = 2 # comment in new file\n",
482
+ "first_count = 1\nsecond_count = 2 # comment in new file\n",
478
483
  )
479
484
  stage_file(temporary_git_repository, "brand_new.py")
480
485
 
@@ -494,19 +499,65 @@ def test_is_test_path_helper_matches_code_rules_patterns() -> None:
494
499
  assert not gate_module.is_test_path("packages/foo/regular_module.py")
495
500
 
496
501
 
497
- def test_validate_content_callable_signature_is_explicit() -> None:
498
- callable_alias_source = inspect.getsource(gate_module).split("\n")
499
- matching_lines = [
500
- each_line
501
- for each_line in callable_alias_source
502
- if "ValidateContentCallable" in each_line and "Callable[" in each_line
503
- ]
504
- assert any("[str, str, str]" in each_line for each_line in matching_lines)
502
+ def test_gate_defers_scope_to_the_gate() -> None:
503
+ """The gate owns scope classification, so its per-file validation must call
504
+ validate_content with ``defer_scope_to_caller=True``. Without that flag the
505
+ enforcer scopes function-length, isolation, and banned-noun violations
506
+ itself rather than returning them for the gate to classify by added line."""
507
+ per_file_source = inspect.getsource(gate_module._scoped_violations_for_file)
508
+ assert "defer_scope_to_caller=True" in per_file_source
509
+
505
510
 
511
+ def test_collect_partitioned_violations_returns_empty_maps_for_two_clean_files(
512
+ temporary_git_repository: Path,
513
+ ) -> None:
514
+ """Two readable, violation-free files yield empty partitions and no skips."""
515
+ first_clean = temporary_git_repository / "first_clean.py"
516
+ second_clean = temporary_git_repository / "second_clean.py"
517
+ first_clean.write_text("first_count = 1\n", encoding="utf-8")
518
+ second_clean.write_text("second_count = 2\n", encoding="utf-8")
519
+
520
+ def fake_validate(_content: str, _path: str, _prior: str = "", **_kwargs: object) -> list[str]:
521
+ return []
522
+
523
+ blocking_by_file, advisory_by_file, skipped_unreadable_count = (
524
+ gate_module._collect_partitioned_violations(
525
+ fake_validate,
526
+ [first_clean, second_clean],
527
+ temporary_git_repository,
528
+ None,
529
+ )
530
+ )
506
531
 
507
- def test_run_gate_uses_each_path_loop_variable() -> None:
508
- run_gate_source = inspect.getsource(gate_module.run_gate)
509
- assert "for each_path in" in run_gate_source
532
+ assert blocking_by_file == {}
533
+ assert advisory_by_file == {}
534
+ assert skipped_unreadable_count == 0
535
+
536
+
537
+ def test_collect_partitioned_violations_counts_unreadable_sibling_as_skip(
538
+ temporary_git_repository: Path,
539
+ ) -> None:
540
+ """A clean file beside an unreadable file yields one skip and no violations."""
541
+ clean_file = temporary_git_repository / "clean.py"
542
+ clean_file.write_text("clean_count = 1\n", encoding="utf-8")
543
+ unreadable_file = temporary_git_repository / "garbled.py"
544
+ unreadable_file.write_bytes(b"\xff\xfe\x00bad")
545
+
546
+ def fake_validate(_content: str, _path: str, _prior: str = "", **_kwargs: object) -> list[str]:
547
+ return []
548
+
549
+ blocking_by_file, advisory_by_file, skipped_unreadable_count = (
550
+ gate_module._collect_partitioned_violations(
551
+ fake_validate,
552
+ [clean_file, unreadable_file],
553
+ temporary_git_repository,
554
+ None,
555
+ )
556
+ )
557
+
558
+ assert blocking_by_file == {}
559
+ assert advisory_by_file == {}
560
+ assert skipped_unreadable_count == 1
510
561
 
511
562
 
512
563
  def test_run_gate_skips_non_utf8_source_without_crashing(
@@ -517,7 +568,9 @@ def test_run_gate_skips_non_utf8_source_without_crashing(
517
568
 
518
569
  UnicodeDecodeError is a ValueError subclass, not OSError. A non-UTF-8
519
570
  source file in the staged set must be skipped (matching whole_file_line_set
520
- behavior), not crash the gate mid-audit.
571
+ behavior) rather than crash the gate mid-audit, and the skip must fail
572
+ closed: a changed file the gate could not validate must never be silently
573
+ approved.
521
574
  """
522
575
  write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
523
576
  commit_all_files(temporary_git_repository, "baseline")
@@ -529,7 +582,90 @@ def test_run_gate_skips_non_utf8_source_without_crashing(
529
582
  monkeypatch.chdir(temporary_git_repository)
530
583
  exit_code = gate_module.main(["--staged"])
531
584
 
532
- assert exit_code in {0, 1}
585
+ assert exit_code == 1
586
+
587
+
588
+ def test_run_gate_fails_closed_when_only_changed_file_is_unreadable(
589
+ temporary_git_repository: Path,
590
+ monkeypatch: pytest.MonkeyPatch,
591
+ ) -> None:
592
+ """A changed file that cannot be validated must not be silently approved.
593
+
594
+ When the only staged code file holds genuine non-UTF-8 bytes and no other
595
+ blocking violation exists, the gate must fail closed (non-zero) rather than
596
+ exit 0, because it never validated that file.
597
+ """
598
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
599
+ commit_all_files(temporary_git_repository, "baseline")
600
+ non_utf8_path = temporary_git_repository / "non_utf8.py"
601
+ non_utf8_path.parent.mkdir(parents=True, exist_ok=True)
602
+ non_utf8_path.write_bytes(b"\xff\xfe\x00bad")
603
+ stage_file(temporary_git_repository, "non_utf8.py")
604
+
605
+ monkeypatch.chdir(temporary_git_repository)
606
+ exit_code = gate_module.main(["--staged"])
607
+
608
+ assert exit_code != 0
609
+
610
+
611
+ def test_run_gate_fails_closed_on_skipped_non_utf8_file_directly(
612
+ tmp_path: Path,
613
+ capsys: pytest.CaptureFixture[str],
614
+ ) -> None:
615
+ """run_gate must fail closed when a changed file is skipped for non-UTF-8.
616
+
617
+ Mirrors the bugteam gate's parity test: a non-UTF-8 code file with no other
618
+ violation must surface the skip and produce a non-zero exit so an
619
+ unvalidated file is never silently approved.
620
+ """
621
+ non_utf8_file = tmp_path / "garbled.py"
622
+ non_utf8_file.write_bytes(b"\xff\xfe\x00bad")
623
+
624
+ def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
625
+ return []
626
+
627
+ exit_code = gate_module.run_gate(
628
+ fake_validate,
629
+ [non_utf8_file],
630
+ tmp_path,
631
+ all_added_lines_by_path=None,
632
+ )
633
+
634
+ captured = capsys.readouterr()
635
+ assert exit_code != 0
636
+ assert "skip unreadable" in captured.err
637
+
638
+
639
+ def test_run_gate_fails_closed_when_clean_file_accompanies_unreadable_file(
640
+ tmp_path: Path,
641
+ capsys: pytest.CaptureFixture[str],
642
+ ) -> None:
643
+ """A clean readable file must not mask an unreadable sibling across files.
644
+
645
+ run_gate aggregates per-file results: even when one staged file validates
646
+ cleanly, an accompanying file that cannot be decoded must still surface the
647
+ skip and force a non-zero exit so the unvalidated file is never approved.
648
+ """
649
+ clean_file = tmp_path / "clean.py"
650
+ clean_file.write_text("first_count = 1\nsecond_count = 2\n", encoding="utf-8")
651
+ unreadable_file = tmp_path / "garbled.py"
652
+ unreadable_file.write_bytes(b"\xff\xfe\x00bad")
653
+
654
+ def fake_validate(
655
+ _content: str, _path: str, _prior: str = "", **_kwargs: object
656
+ ) -> list[str]:
657
+ return []
658
+
659
+ exit_code = gate_module.run_gate(
660
+ fake_validate,
661
+ [clean_file, unreadable_file],
662
+ tmp_path,
663
+ all_added_lines_by_path=None,
664
+ )
665
+
666
+ captured = capsys.readouterr()
667
+ assert exit_code != 0
668
+ assert "skip unreadable" in captured.err
533
669
 
534
670
 
535
671
  def test_check_wrapper_plumb_through_accepts_positional_or_keyword_forwarder() -> None:
@@ -854,6 +990,133 @@ def test_check_wrapper_plumb_through_skips_class_methods_calling_module_delegate
854
990
  )
855
991
 
856
992
 
993
+ def _build_function_module(
994
+ function_name: str, body_line_count: int, leading_lines: int
995
+ ) -> str:
996
+ preamble = "".join("anchor_name\n" for _ in range(leading_lines))
997
+ body = "\n".join(" keep_alive_name" for _ in range(body_line_count))
998
+ return f"{preamble}def {function_name}() -> None:\n{body}\n"
999
+
1000
+
1001
+ def test_main_blocks_when_function_body_grows_past_threshold_with_def_line_untouched(
1002
+ temporary_git_repository: Path,
1003
+ monkeypatch: pytest.MonkeyPatch,
1004
+ ) -> None:
1005
+ """An existing function grown past the blocking threshold by editing its
1006
+ body must be classified blocking even when the ``def`` line is untouched.
1007
+
1008
+ Anchoring the function-length violation to the ``def`` line let the gate
1009
+ treat it as advisory whenever body growth left the definition line
1010
+ outside the added-line set. The violation must surface as blocking
1011
+ regardless of which body line carries the edit.
1012
+ """
1013
+ short_body_count = 5
1014
+ baseline = _build_function_module(
1015
+ "grow_me", body_line_count=short_body_count, leading_lines=0
1016
+ )
1017
+ write_file(temporary_git_repository / "module.py", baseline)
1018
+ commit_all_files(temporary_git_repository, "baseline short function")
1019
+
1020
+ grown_body_count = 70
1021
+ grown = _build_function_module(
1022
+ "grow_me", body_line_count=grown_body_count, leading_lines=0
1023
+ )
1024
+ write_file(temporary_git_repository / "module.py", grown)
1025
+ stage_file(temporary_git_repository, "module.py")
1026
+
1027
+ monkeypatch.chdir(temporary_git_repository)
1028
+ exit_code = gate_module.main(["--staged"])
1029
+
1030
+ assert exit_code == 1
1031
+
1032
+
1033
+ def test_split_violations_blocks_function_length_when_span_intersects_added_lines() -> None:
1034
+ """A function-length issue whose declared span overlaps the diff's added
1035
+ lines is blocking — the body grew, which is exactly Finding B's intent."""
1036
+ validate_content = gate_module.load_validate_content()
1037
+ long_function = _build_function_module(
1038
+ "oversized", body_line_count=70, leading_lines=3
1039
+ )
1040
+ issues = validate_content(long_function, "src/long_module.py", "")
1041
+ function_length_issues = [
1042
+ each_issue for each_issue in issues if "blocking threshold" in each_issue
1043
+ ]
1044
+ assert function_length_issues, f"expected a function-length issue, got {issues!r}"
1045
+ span_def_line = 4
1046
+ inside_span_line = span_def_line + 10
1047
+ blocking, advisory = gate_module.split_violations_by_scope(
1048
+ function_length_issues,
1049
+ all_added_line_numbers={inside_span_line},
1050
+ )
1051
+ assert blocking == function_length_issues
1052
+ assert advisory == []
1053
+
1054
+
1055
+ def test_split_violations_advises_function_length_when_span_misses_added_lines() -> None:
1056
+ """A function-length issue for an untouched pre-existing function — whose
1057
+ declared span does not overlap any added line — is advisory, not blocking.
1058
+ This prevents the over-block regression where every pre-existing >=60-line
1059
+ function in a touched file was forced into the blocking payload."""
1060
+ validate_content = gate_module.load_validate_content()
1061
+ long_function = _build_function_module(
1062
+ "oversized", body_line_count=70, leading_lines=3
1063
+ )
1064
+ issues = validate_content(long_function, "src/long_module.py", "")
1065
+ function_length_issues = [
1066
+ each_issue for each_issue in issues if "blocking threshold" in each_issue
1067
+ ]
1068
+ assert function_length_issues, f"expected a function-length issue, got {issues!r}"
1069
+ line_far_outside_span = 5000
1070
+ blocking, advisory = gate_module.split_violations_by_scope(
1071
+ function_length_issues,
1072
+ all_added_line_numbers={line_far_outside_span},
1073
+ )
1074
+ assert advisory == function_length_issues
1075
+ assert blocking == []
1076
+
1077
+
1078
+ def _isolation_issues_for_home_probe_test() -> list[str]:
1079
+ validate_content = gate_module.load_validate_content()
1080
+ header = "from pathlib import Path\n"
1081
+ test_body = (
1082
+ "def test_reads_home() -> None:\n"
1083
+ " target_path = Path.home()\n"
1084
+ " assert target_path\n"
1085
+ )
1086
+ issues = validate_content(header + test_body, "src/test_module.py", "")
1087
+ return [each_issue for each_issue in issues if "probes" in each_issue]
1088
+
1089
+
1090
+ def test_split_violations_blocks_isolation_when_function_span_intersects_added_lines() -> None:
1091
+ """An isolation issue whose enclosing test-function span overlaps the diff's
1092
+ added lines is blocking — a signature-line change that un-isolates an
1093
+ unchanged-body probe must block, matching the enforcer's terminal path."""
1094
+ isolation_issues = _isolation_issues_for_home_probe_test()
1095
+ assert isolation_issues, "expected an isolation issue from the HOME probe test"
1096
+ signature_line = 2
1097
+ blocking, advisory = gate_module.split_violations_by_scope(
1098
+ isolation_issues,
1099
+ all_added_line_numbers={signature_line},
1100
+ )
1101
+ assert blocking == isolation_issues
1102
+ assert advisory == []
1103
+
1104
+
1105
+ def test_split_violations_advises_isolation_when_function_span_misses_added_lines() -> None:
1106
+ """An isolation issue for an untouched pre-existing probe — whose enclosing
1107
+ test-function span does not overlap any added line — is advisory, not
1108
+ blocking, mirroring the function-length scope contract."""
1109
+ isolation_issues = _isolation_issues_for_home_probe_test()
1110
+ assert isolation_issues, "expected an isolation issue from the HOME probe test"
1111
+ line_far_outside_span = 5000
1112
+ blocking, advisory = gate_module.split_violations_by_scope(
1113
+ isolation_issues,
1114
+ all_added_line_numbers={line_far_outside_span},
1115
+ )
1116
+ assert advisory == isolation_issues
1117
+ assert blocking == []
1118
+
1119
+
857
1120
  def test_renamed_file_source_map_since_uses_null_byte_separator(
858
1121
  temporary_git_repository: Path,
859
1122
  monkeypatch: pytest.MonkeyPatch,
@@ -896,3 +1159,349 @@ def test_renamed_file_source_map_since_uses_null_byte_separator(
896
1159
  assert rename_map == {
897
1160
  "destination_with\ttab.py": "source_with\ttab.py",
898
1161
  }
1162
+
1163
+
1164
+ def _oversized_function_text(function_name: str) -> str:
1165
+ body = "\n".join(" keep_alive_name" for _ in range(70))
1166
+ return f"def {function_name}() -> None:\n{body}\n"
1167
+
1168
+
1169
+ def _short_function_text(function_name: str) -> str:
1170
+ return f"def {function_name}() -> None:\n keep_alive_name\n"
1171
+
1172
+
1173
+ def test_main_blocks_sixth_long_function_on_added_lines_past_document_order(
1174
+ temporary_git_repository: Path,
1175
+ monkeypatch: pytest.MonkeyPatch,
1176
+ ) -> None:
1177
+ """bugbot-2: with five pre-existing untouched long functions ahead of it in
1178
+ document order, growing the sixth function past the threshold on staged
1179
+ lines must still block at the gate. The gate scopes by added lines, so the
1180
+ in-scope sixth violation blocks regardless of how many untouched ones
1181
+ precede it."""
1182
+ leading_long_functions = "".join(
1183
+ _oversized_function_text(f"leading_long_{each_index}")
1184
+ for each_index in range(5)
1185
+ )
1186
+ baseline = leading_long_functions + _short_function_text("target_function")
1187
+ write_file(temporary_git_repository / "module.py", baseline)
1188
+ commit_all_files(temporary_git_repository, "five long functions plus a short sixth")
1189
+
1190
+ grown = leading_long_functions + _oversized_function_text("target_function")
1191
+ write_file(temporary_git_repository / "module.py", grown)
1192
+ stage_file(temporary_git_repository, "module.py")
1193
+
1194
+ monkeypatch.chdir(temporary_git_repository)
1195
+ exit_code = gate_module.main(["--staged"])
1196
+
1197
+ assert exit_code == 1, (
1198
+ "the sixth long function — the only one on staged lines — must block "
1199
+ "even though five untouched long functions precede it in document order"
1200
+ )
1201
+
1202
+
1203
+ def _home_probe_test_text(test_name: str) -> str:
1204
+ return (
1205
+ f"def {test_name}() -> None:\n"
1206
+ " target_path = Path.home()\n"
1207
+ " assert target_path\n"
1208
+ )
1209
+
1210
+
1211
+ def _clean_test_text(test_name: str) -> str:
1212
+ return f"def {test_name}() -> None:\n assert 1 + 1 == 2\n"
1213
+
1214
+
1215
+ def test_main_blocks_sixth_isolation_probe_on_added_lines_past_document_order(
1216
+ temporary_git_repository: Path,
1217
+ monkeypatch: pytest.MonkeyPatch,
1218
+ ) -> None:
1219
+ """bugbot-2 mirror: with five pre-existing untouched HOME probes ahead of it
1220
+ in document order, adding a HOME probe to the sixth test on staged lines
1221
+ must still block at the gate. The gate scopes by added lines, so the
1222
+ in-scope sixth probe blocks regardless of how many untouched ones
1223
+ precede it."""
1224
+ header = "from pathlib import Path\n"
1225
+ leading_probe_tests = "".join(
1226
+ _home_probe_test_text(f"test_leading_probe_{each_index}")
1227
+ for each_index in range(5)
1228
+ )
1229
+ baseline = header + leading_probe_tests + _clean_test_text("test_target_probe")
1230
+ write_file(temporary_git_repository / "test_module.py", baseline)
1231
+ commit_all_files(temporary_git_repository, "five probe tests plus a clean sixth")
1232
+
1233
+ grown = header + leading_probe_tests + _home_probe_test_text("test_target_probe")
1234
+ write_file(temporary_git_repository / "test_module.py", grown)
1235
+ stage_file(temporary_git_repository, "test_module.py")
1236
+
1237
+ monkeypatch.chdir(temporary_git_repository)
1238
+ exit_code = gate_module.main(["--staged"])
1239
+
1240
+ assert exit_code == 1, (
1241
+ "the sixth HOME probe — the only one on staged lines — must block even "
1242
+ "though five untouched probes precede it in document order"
1243
+ )
1244
+
1245
+
1246
+ def _banned_noun_function_text(index: int) -> str:
1247
+ return (
1248
+ f"def leading_{index}(canned_results: int) -> int:\n"
1249
+ f" return canned_results\n"
1250
+ )
1251
+
1252
+
1253
+ def test_main_blocks_banned_noun_on_added_lines_past_document_order(
1254
+ temporary_git_repository: Path,
1255
+ monkeypatch: pytest.MonkeyPatch,
1256
+ ) -> None:
1257
+ """loop7-P1: with three pre-existing untouched banned-noun identifiers ahead
1258
+ of it in document order, introducing a fourth banned-noun on a staged line
1259
+ must still block at the gate. The gate scopes by added lines, so the
1260
+ in-scope identifier blocks regardless of how many untouched ones precede
1261
+ it."""
1262
+ leading_count = 3
1263
+ leading_functions = "".join(
1264
+ _banned_noun_function_text(each_index) for each_index in range(leading_count)
1265
+ )
1266
+ baseline = leading_functions + "def placeholder() -> int:\n return 0\n"
1267
+ write_file(temporary_git_repository / "module.py", baseline)
1268
+ commit_all_files(temporary_git_repository, "three banned nouns plus a clean function")
1269
+
1270
+ grown = leading_functions + "def aggregate(holiday_result: int) -> int:\n return holiday_result\n"
1271
+ write_file(temporary_git_repository / "module.py", grown)
1272
+ stage_file(temporary_git_repository, "module.py")
1273
+
1274
+ monkeypatch.chdir(temporary_git_repository)
1275
+ exit_code = gate_module.main(["--staged"])
1276
+
1277
+ assert exit_code == 1, (
1278
+ "the fourth banned-noun identifier — the only one on staged lines — must "
1279
+ "block even though three untouched ones precede it in document order"
1280
+ )
1281
+
1282
+
1283
+ def _load_bugteam_gate_module() -> ModuleType:
1284
+ bugteam_scripts_dir = (
1285
+ Path(__file__).resolve().parents[4]
1286
+ / "skills"
1287
+ / "bugteam"
1288
+ / "scripts"
1289
+ )
1290
+ if str(bugteam_scripts_dir) not in sys.path:
1291
+ sys.path.insert(0, str(bugteam_scripts_dir))
1292
+ module_path = bugteam_scripts_dir / "bugteam_code_rules_gate.py"
1293
+ spec = importlib.util.spec_from_file_location("bugteam_code_rules_gate", module_path)
1294
+ assert spec is not None
1295
+ assert spec.loader is not None
1296
+ module = importlib.util.module_from_spec(spec)
1297
+ spec.loader.exec_module(module)
1298
+ return module
1299
+
1300
+
1301
+ def test_both_gates_classify_wrapper_plumb_through_identically() -> None:
1302
+ """The bugteam and _shared gate copies of check_wrapper_plumb_through must
1303
+ return identical findings. A class method calling a module-level delegate
1304
+ is not a wrapper; both gates must exclude it rather than one emitting a
1305
+ false positive the other does not."""
1306
+ bugteam_gate = _load_bugteam_gate_module()
1307
+ class_method_calling_delegate = (
1308
+ "def fetch(target, *, retries=3):\n"
1309
+ " return target\n"
1310
+ "\n"
1311
+ "class MyService:\n"
1312
+ " def public_method(self, target):\n"
1313
+ " return fetch(target)\n"
1314
+ )
1315
+ nested_call_inside_delegate_argument = (
1316
+ "def delegate(value, *, retries=3):\n"
1317
+ " return value\n"
1318
+ "\n"
1319
+ "def helper(value):\n"
1320
+ " return value\n"
1321
+ "\n"
1322
+ "def public_caller(value):\n"
1323
+ " return delegate(helper(value))\n"
1324
+ )
1325
+ name_call_dropping_kwarg = (
1326
+ "def delegate(value, *, retries=3):\n"
1327
+ " return value\n"
1328
+ "\n"
1329
+ "def public_wrapper(value):\n"
1330
+ " return delegate(value)\n"
1331
+ )
1332
+ for each_source in (
1333
+ class_method_calling_delegate,
1334
+ nested_call_inside_delegate_argument,
1335
+ name_call_dropping_kwarg,
1336
+ ):
1337
+ shared_issues = gate_module.check_wrapper_plumb_through(each_source, "module.py")
1338
+ bugteam_issues = bugteam_gate.check_wrapper_plumb_through(each_source, "module.py")
1339
+ assert shared_issues == bugteam_issues, (
1340
+ "both gate copies of check_wrapper_plumb_through must classify "
1341
+ f"identically; shared={shared_issues!r} bugteam={bugteam_issues!r}"
1342
+ )
1343
+
1344
+
1345
+ def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
1346
+ """check_wrapper_plumb_through must stay under the enforcer's function-length
1347
+ blocking threshold so editing it (e.g. aligning the two gate copies) does
1348
+ not itself trip the gate; its signature-index and class-method-id collection
1349
+ are extracted into helpers."""
1350
+ enforcer_span = inspect.getsource(gate_module.check_wrapper_plumb_through)
1351
+ declared_line_count = len(enforcer_span.splitlines())
1352
+ blocking_threshold = 60
1353
+ assert declared_line_count < blocking_threshold, (
1354
+ f"check_wrapper_plumb_through is {declared_line_count} lines; extract "
1355
+ "helpers to keep it under the function-length blocking threshold"
1356
+ )
1357
+
1358
+
1359
+ def _banned_noun_parameter_issues() -> list[str]:
1360
+ validate_content = gate_module.load_validate_content()
1361
+ source = (
1362
+ "def aggregate(canned_results: int) -> int:\n"
1363
+ " doubled = canned_results * 2\n"
1364
+ " return doubled\n"
1365
+ )
1366
+ issues = validate_content(source, "src/module.py", "")
1367
+ return [each_issue for each_issue in issues if "banned noun" in each_issue]
1368
+
1369
+
1370
+ def test_split_violations_blocks_banned_noun_when_binding_line_is_added() -> None:
1371
+ """A banned-noun binding is blocking when its own binding line is among the
1372
+ added lines. The gate reconstructs the one-line binding span through the
1373
+ same shared extractor registry it uses for function-length and isolation,
1374
+ rather than relying on the bare ``Line N:`` prefix branch."""
1375
+ banned_noun_issues = _banned_noun_parameter_issues()
1376
+ assert banned_noun_issues, "expected a banned-noun parameter issue"
1377
+ parameter_binding_line = 1
1378
+ blocking, advisory = gate_module.split_violations_by_scope(
1379
+ banned_noun_issues,
1380
+ all_added_line_numbers={parameter_binding_line},
1381
+ )
1382
+ assert blocking == banned_noun_issues
1383
+ assert advisory == []
1384
+
1385
+
1386
+ def test_split_violations_advises_banned_noun_when_binding_line_untouched() -> None:
1387
+ """A banned-noun binding whose own line is not among the added lines is
1388
+ advisory — editing an unrelated body line does not pull a pre-existing
1389
+ binding into scope, mirroring the companion exact-match identifier check."""
1390
+ banned_noun_issues = _banned_noun_parameter_issues()
1391
+ assert banned_noun_issues, "expected a banned-noun parameter issue"
1392
+ unrelated_body_line = 2
1393
+ blocking, advisory = gate_module.split_violations_by_scope(
1394
+ banned_noun_issues,
1395
+ all_added_line_numbers={unrelated_body_line},
1396
+ )
1397
+ assert advisory == banned_noun_issues
1398
+ assert blocking == []
1399
+
1400
+
1401
+ def test_banned_noun_span_range_covers_only_the_binding_line() -> None:
1402
+ """The reconstructed span is the binding line alone — one line, never the
1403
+ enclosing function span. A parameter declared on a ``def`` line yields a
1404
+ range covering only that line, so an unrelated body edit cannot pull the
1405
+ pre-existing binding into scope."""
1406
+ banned_noun_issues = _banned_noun_parameter_issues()
1407
+ assert banned_noun_issues, "expected a banned-noun parameter issue"
1408
+ parameter_binding_line = 1
1409
+ span = gate_module.banned_noun_span_range(banned_noun_issues[0])
1410
+ assert span == range(parameter_binding_line, parameter_binding_line + 1)
1411
+ assert len(span) == 1
1412
+
1413
+
1414
+ def test_main_staged_mode_validates_staged_blob_not_working_tree(
1415
+ temporary_git_repository: Path,
1416
+ monkeypatch: pytest.MonkeyPatch,
1417
+ ) -> None:
1418
+ """Staged mode validates the staged blob, not the working tree.
1419
+
1420
+ A blocking violation lives in the staged blob, but the working tree has
1421
+ been edited afterward to remove it. The gate must still block because it
1422
+ scopes added lines from the staged index and must read its content from
1423
+ the same staged source rather than the diverged working tree.
1424
+ """
1425
+ write_file(temporary_git_repository / "module.py", "first_count = 1\n")
1426
+ commit_all_files(temporary_git_repository, "initial")
1427
+ staged_content_with_banned_identifier = (
1428
+ "first_count = 1\n"
1429
+ "def compute_total(operand):\n"
1430
+ " result = operand + 1\n"
1431
+ " return result\n"
1432
+ )
1433
+ write_file(
1434
+ temporary_git_repository / "module.py",
1435
+ staged_content_with_banned_identifier,
1436
+ )
1437
+ stage_file(temporary_git_repository, "module.py")
1438
+ clean_working_tree_content = (
1439
+ "first_count = 1\n"
1440
+ "def compute_total(operand: int) -> int:\n"
1441
+ " return operand + 1\n"
1442
+ )
1443
+ write_file(
1444
+ temporary_git_repository / "module.py",
1445
+ clean_working_tree_content,
1446
+ )
1447
+
1448
+ monkeypatch.chdir(temporary_git_repository)
1449
+ exit_code = gate_module.main(["--staged"])
1450
+
1451
+ assert exit_code == 1, (
1452
+ "the staged blob carries a blocking violation; the gate must block "
1453
+ "even though the working tree was edited clean afterward"
1454
+ )
1455
+
1456
+
1457
+ def test_main_staged_mode_blocks_when_staged_file_absent_from_working_tree(
1458
+ temporary_git_repository: Path,
1459
+ monkeypatch: pytest.MonkeyPatch,
1460
+ ) -> None:
1461
+ """A staged blocking violation must block even when the working tree file
1462
+ is gone. Staging a violating file and then deleting it from the working
1463
+ tree leaves the violation only in the staged blob; the gate must validate
1464
+ that blob rather than skip the path for failing a working-tree existence
1465
+ check."""
1466
+ write_file(temporary_git_repository / "baseline.py", "first_count = 1\n")
1467
+ commit_all_files(temporary_git_repository, "initial")
1468
+ staged_content_with_banned_identifier = (
1469
+ "def compute_total(operand):\n"
1470
+ " result = operand + 1\n"
1471
+ " return result\n"
1472
+ )
1473
+ write_file(
1474
+ temporary_git_repository / "module.py",
1475
+ staged_content_with_banned_identifier,
1476
+ )
1477
+ stage_file(temporary_git_repository, "module.py")
1478
+ (temporary_git_repository / "module.py").unlink()
1479
+
1480
+ monkeypatch.chdir(temporary_git_repository)
1481
+ exit_code = gate_module.main(["--staged"])
1482
+
1483
+ assert exit_code == 1, (
1484
+ "the staged blob carries a blocking violation; the gate must block "
1485
+ "even though the file was deleted from the working tree after staging"
1486
+ )
1487
+
1488
+
1489
+ def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
1490
+ temporary_git_repository: Path,
1491
+ monkeypatch: pytest.MonkeyPatch,
1492
+ ) -> None:
1493
+ """A staged deletion is not in the index, so its staged blob cannot be read.
1494
+ The gate must skip such a path cleanly rather than counting it as an
1495
+ unreadable file and failing closed. With no other staged violation, the
1496
+ gate must exit zero."""
1497
+ write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
1498
+ commit_all_files(temporary_git_repository, "initial")
1499
+ run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
1500
+
1501
+ monkeypatch.chdir(temporary_git_repository)
1502
+ exit_code = gate_module.main(["--staged"])
1503
+
1504
+ assert exit_code == 0, (
1505
+ "a staged deletion has no staged blob; the gate must skip it cleanly "
1506
+ "rather than fail closed as if the file were unreadable"
1507
+ )