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.
- package/CLAUDE.md +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
- package/skills/pr-converge/SKILL.md +5 -0
- package/skills/pr-converge/reference/per-tick.md +14 -5
- package/skills/pr-converge/reference/state-schema.md +7 -3
- package/skills/pr-converge/scripts/check_convergence.py +27 -1
- 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", "
|
|
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
|
-
"
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
assert
|
|
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)
|
|
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
|
|
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
|
+
)
|