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