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