claude-dev-env 1.93.1 → 1.94.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 (215) hide show
  1. package/_shared/pr-loop/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/audit-contract.md +1 -1
  3. package/_shared/pr-loop/gh-payloads.md +3 -3
  4. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  5. package/_shared/pr-loop/scripts/README.md +2 -2
  6. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  7. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
  8. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  9. package/_shared/pr-loop/state-schema.md +3 -22
  10. package/bin/CLAUDE.md +1 -1
  11. package/bin/install.mjs +2 -7
  12. package/bin/install.test.mjs +10 -4
  13. package/hooks/blocking/CLAUDE.md +2 -0
  14. package/hooks/blocking/pii_commit_command.py +398 -0
  15. package/hooks/blocking/pii_payload_scan.py +133 -0
  16. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  17. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  18. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  19. package/hooks/hooks_constants/CLAUDE.md +2 -2
  20. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  21. package/hooks/hooks_constants/local_identity.py +65 -15
  22. package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
  23. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  24. package/hooks/hooks_constants/test_local_identity.py +68 -0
  25. package/package.json +1 -1
  26. package/scripts/CLAUDE.md +10 -1
  27. package/scripts/Get-SessionAccount.ps1 +210 -0
  28. package/scripts/tests/CLAUDE.md +15 -1
  29. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  30. package/skills/CLAUDE.md +3 -25
  31. package/skills/anthropic-plan/SKILL.md +1 -13
  32. package/skills/autoconverge/CLAUDE.md +5 -2
  33. package/skills/autoconverge/SKILL.md +112 -387
  34. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  35. package/skills/autoconverge/reference/closing-report.md +9 -2
  36. package/skills/autoconverge/reference/convergence.md +26 -22
  37. package/skills/autoconverge/reference/headless-safety.md +44 -0
  38. package/skills/autoconverge/reference/multi-pr.md +88 -0
  39. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  40. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  43. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  44. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  45. package/skills/autoconverge/workflow/converge.mjs +284 -94
  46. package/skills/bugteam/CLAUDE.md +1 -1
  47. package/skills/bugteam/CONSTRAINTS.md +3 -3
  48. package/skills/bugteam/PROMPTS.md +8 -19
  49. package/skills/bugteam/SKILL.md +13 -14
  50. package/skills/bugteam/reference/CLAUDE.md +1 -1
  51. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  52. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  53. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  54. package/skills/bugteam/reference/team-setup.md +11 -11
  55. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  56. package/skills/bugteam/scripts/README.md +20 -8
  57. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  58. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  59. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  60. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  61. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  62. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  63. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  64. package/skills/copilot-finding-triage/SKILL.md +80 -28
  65. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  66. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  67. package/skills/everything-search/SKILL.md +1 -2
  68. package/skills/grokify/SKILL.md +58 -0
  69. package/skills/grokify/templates/handoff-template.md +92 -0
  70. package/skills/orchestrator/SKILL.md +12 -8
  71. package/skills/orchestrator-refresh/SKILL.md +3 -3
  72. package/skills/pr-converge/SKILL.md +132 -233
  73. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  74. package/skills/pr-converge/reference/examples.md +37 -25
  75. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  76. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  77. package/skills/pr-converge/reference/per-tick.md +64 -41
  78. package/skills/pr-converge/reference/state-schema.md +27 -2
  79. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  80. package/skills/pr-converge/scripts/README.md +4 -3
  81. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  82. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  83. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  84. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  85. package/skills/pr-converge/scripts/conftest.py +8 -0
  86. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  87. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  88. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  89. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  90. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  91. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  92. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  93. package/skills/privacy-hygiene/SKILL.md +4 -3
  94. package/skills/reviewer-gates/SKILL.md +26 -8
  95. package/skills/session-log/SKILL.md +11 -12
  96. package/skills/session-tidy/SKILL.md +40 -30
  97. package/skills/skill-builder/CLAUDE.md +13 -10
  98. package/skills/skill-builder/SKILL.md +31 -31
  99. package/skills/skill-builder/references/CLAUDE.md +10 -6
  100. package/skills/skill-builder/references/delegation-map.md +13 -4
  101. package/skills/skill-builder/references/description-field.md +113 -0
  102. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  103. package/skills/skill-builder/references/skill-modularity.md +113 -0
  104. package/skills/skill-builder/references/skill-types.md +4 -0
  105. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  106. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  107. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  108. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  109. package/skills/skill-builder/workflows/new-skill.md +34 -10
  110. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  111. package/skills/usage-pause/SKILL.md +10 -14
  112. package/skills/bdd-protocol/CLAUDE.md +0 -26
  113. package/skills/bdd-protocol/SKILL.md +0 -30
  114. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  115. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  116. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  117. package/skills/bg-agent/CLAUDE.md +0 -17
  118. package/skills/bg-agent/SKILL.md +0 -69
  119. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  120. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  121. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  122. package/skills/caveman/CLAUDE.md +0 -15
  123. package/skills/caveman/SKILL.md +0 -38
  124. package/skills/code/CLAUDE.md +0 -17
  125. package/skills/code/SKILL.md +0 -178
  126. package/skills/deep-research/CLAUDE.md +0 -17
  127. package/skills/deep-research/SKILL.md +0 -80
  128. package/skills/findbugs/CLAUDE.md +0 -20
  129. package/skills/findbugs/SKILL.md +0 -241
  130. package/skills/fixbugs/CLAUDE.md +0 -19
  131. package/skills/fixbugs/SKILL.md +0 -142
  132. package/skills/gh-paginate/CLAUDE.md +0 -18
  133. package/skills/gh-paginate/SKILL.md +0 -84
  134. package/skills/gotcha/CLAUDE.md +0 -33
  135. package/skills/gotcha/SKILL.md +0 -73
  136. package/skills/implement/CLAUDE.md +0 -27
  137. package/skills/implement/SKILL.md +0 -66
  138. package/skills/implement/scripts/CLAUDE.md +0 -22
  139. package/skills/implement/scripts/append_note.py +0 -133
  140. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  141. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  142. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  143. package/skills/implement/scripts/test_append_note.py +0 -191
  144. package/skills/log-audit/CLAUDE.md +0 -20
  145. package/skills/log-audit/SKILL.md +0 -66
  146. package/skills/log-audit/reference/CLAUDE.md +0 -9
  147. package/skills/log-audit/reference/charter.md +0 -52
  148. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  149. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  150. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  151. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  152. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  153. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  154. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  155. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  156. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  157. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  158. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  159. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  160. package/skills/logifix/CLAUDE.md +0 -36
  161. package/skills/logifix/SKILL.md +0 -69
  162. package/skills/logifix/scripts/CLAUDE.md +0 -16
  163. package/skills/logifix/scripts/logifix.ps1 +0 -205
  164. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  165. package/skills/monitor-open-prs/SKILL.md +0 -88
  166. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  167. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  168. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  169. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  170. package/skills/post-audit-findings/SKILL.md +0 -80
  171. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  172. package/skills/pr-consistency-audit/SKILL.md +0 -116
  173. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  174. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  175. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  176. package/skills/pr-fix-protocol/SKILL.md +0 -65
  177. package/skills/pr-review-responder/CLAUDE.md +0 -35
  178. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  179. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  180. package/skills/pr-review-responder/README.md +0 -168
  181. package/skills/pr-review-responder/SKILL.md +0 -203
  182. package/skills/pr-review-responder/TESTING.md +0 -350
  183. package/skills/pr-scope-resolve/SKILL.md +0 -45
  184. package/skills/pre-compact/CLAUDE.md +0 -24
  185. package/skills/pre-compact/SKILL.md +0 -134
  186. package/skills/qbug/CLAUDE.md +0 -40
  187. package/skills/qbug/SKILL.md +0 -387
  188. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  189. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  190. package/skills/refine/CLAUDE.md +0 -44
  191. package/skills/refine/SKILL.md +0 -262
  192. package/skills/refine/templates/CLAUDE.md +0 -17
  193. package/skills/refine/templates/implementation-notes-template.html +0 -56
  194. package/skills/refine/templates/plan-template.md +0 -60
  195. package/skills/research-mode/CLAUDE.md +0 -35
  196. package/skills/research-mode/SKILL.md +0 -53
  197. package/skills/structure-prompt/CLAUDE.md +0 -42
  198. package/skills/structure-prompt/SKILL.md +0 -44
  199. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  200. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  201. package/skills/structure-prompt/reference/block-classification.md +0 -27
  202. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  203. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  204. package/skills/structure-prompt/reference/cleanup.md +0 -33
  205. package/skills/structure-prompt/reference/constraints.md +0 -33
  206. package/skills/structure-prompt/reference/directives.md +0 -37
  207. package/skills/structure-prompt/reference/examples.md +0 -72
  208. package/skills/structure-prompt/reference/instantiation.md +0 -51
  209. package/skills/structure-prompt/reference/output-contract.md +0 -72
  210. package/skills/structure-prompt/reference/per-category.md +0 -23
  211. package/skills/structure-prompt/reference/persona.md +0 -38
  212. package/skills/structure-prompt/reference/research.md +0 -33
  213. package/skills/structure-prompt/reference/structure.md +0 -28
  214. package/skills/verified-build/CLAUDE.md +0 -33
  215. package/skills/verified-build/SKILL.md +0 -33
@@ -1,1066 +1,47 @@
1
- from __future__ import annotations
2
-
3
- import inspect
4
- import subprocess
5
- import sys
6
- import unittest.mock
7
- from pathlib import Path
8
- import pytest
9
-
10
- SCRIPT_DIRECTORY = Path(__file__).resolve().parent
11
- if str(SCRIPT_DIRECTORY) not in sys.path:
12
- sys.path.insert(0, str(SCRIPT_DIRECTORY))
13
-
14
- import bugteam_code_rules_gate as gate_module
15
-
16
-
17
- def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
18
- completion = subprocess.run(
19
- ["git", *arguments],
20
- cwd=str(repository_root),
21
- capture_output=True,
22
- text=True,
23
- encoding="utf-8",
24
- errors="replace",
25
- check=True,
26
- )
27
- return completion.stdout
28
-
29
-
30
- def initialize_git_repository(repository_root: Path) -> None:
31
- run_git_in_repository(repository_root, "init")
32
- run_git_in_repository(repository_root, "symbolic-ref", "HEAD", "refs/heads/main")
33
- run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
34
- run_git_in_repository(repository_root, "config", "user.name", "Test")
35
- run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
36
- disabled_hooks_directory = repository_root / "disabled-git-hooks"
37
- disabled_hooks_directory.mkdir()
38
- run_git_in_repository(
39
- repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
40
- )
41
-
42
-
43
- def commit_all_files(repository_root: Path, commit_message: str) -> None:
44
- run_git_in_repository(repository_root, "add", "-A")
45
- run_git_in_repository(repository_root, "commit", "-m", commit_message)
46
-
47
-
48
- def write_file(file_path: Path, content: str) -> None:
49
- file_path.parent.mkdir(parents=True, exist_ok=True)
50
- file_path.write_text(content, encoding="utf-8")
51
-
52
-
53
- def stage_file(repository_root: Path, relative_path: str) -> None:
54
- run_git_in_repository(repository_root, "add", "--", relative_path)
55
-
56
-
57
- @pytest.fixture()
58
- def temporary_git_repository(tmp_path: Path) -> Path:
59
- repository_root = tmp_path / "repository_under_test"
60
- repository_root.mkdir()
61
- initialize_git_repository(repository_root)
62
- return repository_root
63
-
64
-
65
- def test_paths_from_git_staged_returns_staged_files(
66
- temporary_git_repository: Path,
67
- ) -> None:
68
- write_file(temporary_git_repository / "committed_file.py", "one = 1\n")
69
- commit_all_files(temporary_git_repository, "initial")
70
- write_file(temporary_git_repository / "newly_staged_file.py", "two = 2\n")
71
- write_file(temporary_git_repository / "unstaged_file.py", "three = 3\n")
72
- stage_file(temporary_git_repository, "newly_staged_file.py")
73
-
74
- staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
75
-
76
- staged_names = {path.name for path in staged_paths}
77
- assert "newly_staged_file.py" in staged_names
78
- assert "unstaged_file.py" not in staged_names
79
- assert "committed_file.py" not in staged_names
80
-
81
-
82
- def test_added_lines_for_staged_file_reports_new_lines(
83
- temporary_git_repository: Path,
84
- ) -> None:
85
- write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
86
- commit_all_files(temporary_git_repository, "baseline")
87
- write_file(
88
- temporary_git_repository / "target.py",
89
- "first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
90
- )
91
- stage_file(temporary_git_repository, "target.py")
92
-
93
- added_line_numbers = gate_module.added_lines_for_staged_file(
94
- temporary_git_repository,
95
- "target.py",
96
- )
97
-
98
- assert 3 in added_line_numbers
99
- assert 4 in added_line_numbers
100
- assert 1 not in added_line_numbers
101
- assert 2 not in added_line_numbers
102
-
103
-
104
- def test_added_lines_for_staged_file_treats_new_file_as_fully_added(
105
- temporary_git_repository: Path,
106
- ) -> None:
107
- write_file(temporary_git_repository / "existing.py", "ignored = 0\n")
108
- commit_all_files(temporary_git_repository, "baseline")
109
- write_file(
110
- temporary_git_repository / "brand_new.py",
111
- "alpha = 1\nbeta = 2\ngamma = 3\n",
112
- )
113
- stage_file(temporary_git_repository, "brand_new.py")
114
-
115
- added_line_numbers = gate_module.added_lines_for_staged_file(
116
- temporary_git_repository,
117
- "brand_new.py",
118
- )
119
-
120
- assert added_line_numbers == {1, 2, 3}
121
-
122
-
123
- def test_paths_from_git_staged_uses_null_delimiter(
124
- temporary_git_repository: Path,
125
- ) -> None:
126
- write_file(temporary_git_repository / "first.py", "a = 1\n")
127
- write_file(temporary_git_repository / "second.py", "b = 2\n")
128
- commit_all_files(temporary_git_repository, "baseline")
129
- write_file(temporary_git_repository / "first.py", "a = 10\n")
130
- write_file(temporary_git_repository / "second.py", "b = 20\n")
131
- stage_file(temporary_git_repository, "first.py")
132
- stage_file(temporary_git_repository, "second.py")
133
-
134
- staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
135
-
136
- staged_names = {path.name for path in staged_paths}
137
- assert staged_names == {"first.py", "second.py"}
138
-
139
-
140
- def test_paths_from_git_staged_warns_and_skips_non_utf8_filename(
141
- tmp_path: Path,
142
- capsys: pytest.CaptureFixture[str],
143
- ) -> None:
144
- non_utf8_raw = b"valid.py\x00\xff\xfe_bad.py\x00"
145
- mock_completed = unittest.mock.MagicMock()
146
- mock_completed.returncode = 0
147
- mock_completed.stdout = non_utf8_raw
148
-
149
- with unittest.mock.patch("subprocess.run", return_value=mock_completed):
150
- result_paths = gate_module.paths_from_git_staged(tmp_path)
151
-
152
- captured = capsys.readouterr()
153
- assert "non-UTF-8" in captured.err
154
- assert len(result_paths) == 1
155
- assert result_paths[0].name == "valid.py"
156
-
157
-
158
- def test_staged_added_lines_by_file_maps_every_staged_code_file(
159
- temporary_git_repository: Path,
160
- ) -> None:
161
- write_file(temporary_git_repository / "already_committed.py", "zero = 0\n")
162
- commit_all_files(temporary_git_repository, "initial")
163
- write_file(
164
- temporary_git_repository / "already_committed.py",
165
- "zero = 0\nappended = 1\n",
166
- )
167
- write_file(temporary_git_repository / "added_file.py", "only = 1\n")
168
- stage_file(temporary_git_repository, "already_committed.py")
169
- stage_file(temporary_git_repository, "added_file.py")
170
-
171
- staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
172
- added_lines_map = gate_module.added_lines_by_file_staged(
173
- temporary_git_repository,
174
- staged_paths,
175
- )
176
-
177
- resolved_repository_root = temporary_git_repository.resolve()
178
- assert added_lines_map[resolved_repository_root / "already_committed.py"] == {2}
179
- assert added_lines_map[resolved_repository_root / "added_file.py"] == {1}
180
-
181
-
182
- def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
183
- temporary_git_repository: Path,
184
- monkeypatch: pytest.MonkeyPatch,
185
- ) -> None:
186
- write_file(temporary_git_repository / "module.py", "first_count = 1\n")
187
- commit_all_files(temporary_git_repository, "initial")
188
- staged_content_with_banned_identifier = (
189
- "first_count = 1\n"
190
- "def compute_total(operand):\n"
191
- " result = operand + 1\n"
192
- " return result\n"
193
- )
194
- write_file(
195
- temporary_git_repository / "module.py",
196
- staged_content_with_banned_identifier,
197
- )
198
- stage_file(temporary_git_repository, "module.py")
199
-
200
- monkeypatch.chdir(temporary_git_repository)
201
- exit_code = gate_module.main(["--staged"])
202
-
203
- assert exit_code == 1
204
-
205
-
206
- def test_main_staged_mode_passes_when_no_staged_violations(
207
- temporary_git_repository: Path,
208
- monkeypatch: pytest.MonkeyPatch,
209
- ) -> None:
210
- write_file(temporary_git_repository / "module.py", "first_count = 1\n")
211
- commit_all_files(temporary_git_repository, "initial")
212
- write_file(
213
- temporary_git_repository / "module.py", "first_count = 1\nsecond_count = 2\n"
214
- )
215
- stage_file(temporary_git_repository, "module.py")
216
-
217
- monkeypatch.chdir(temporary_git_repository)
218
- exit_code = gate_module.main(["--staged"])
219
-
220
- assert exit_code == 0
221
-
222
-
223
- def test_main_staged_mode_exits_zero_when_nothing_staged(
224
- temporary_git_repository: Path,
225
- monkeypatch: pytest.MonkeyPatch,
226
- ) -> None:
227
- write_file(temporary_git_repository / "module.py", "first_count = 1\n")
228
- commit_all_files(temporary_git_repository, "initial")
229
-
230
- monkeypatch.chdir(temporary_git_repository)
231
- exit_code = gate_module.main(["--staged"])
232
-
233
- assert exit_code == 0
234
-
235
-
236
- def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
237
- temporary_git_repository: Path,
238
- ) -> None:
239
- write_file(
240
- temporary_git_repository / "existing.py",
241
- "alpha = 1\nbeta = 2\ngamma = 3\n",
242
- )
243
- commit_all_files(temporary_git_repository, "baseline")
244
- write_file(temporary_git_repository / "existing.py", "alpha = 1\nbeta = 2\n")
245
- stage_file(temporary_git_repository, "existing.py")
246
-
247
- added_line_numbers = gate_module.added_lines_for_staged_file(
248
- temporary_git_repository,
249
- "existing.py",
250
- )
251
-
252
- assert added_line_numbers == set()
253
-
254
-
255
- def test_is_file_absent_in_index_head_does_not_exist_in_module() -> None:
256
- assert not hasattr(gate_module, "is_file_absent_in_index_head")
257
-
258
-
259
- def test_staged_file_line_count_raises_on_git_show_failure(
260
- tmp_path: Path,
261
- capsys: pytest.CaptureFixture[str],
262
- ) -> None:
263
- """git show failure must surface as SystemExit + stderr, never silent 0."""
264
- failing_completed = unittest.mock.MagicMock()
265
- failing_completed.returncode = 128
266
- failing_completed.stdout = ""
267
- failing_completed.stderr = "fatal: bad object :missing\n"
268
- with unittest.mock.patch("subprocess.run", return_value=failing_completed):
269
- with pytest.raises(SystemExit):
270
- gate_module.staged_file_line_count(tmp_path, "missing.py")
271
- captured = capsys.readouterr()
272
- assert "git show" in captured.err
273
- assert "fatal: bad object" in captured.err
274
-
275
-
276
- def test_is_staged_file_newly_added_raises_on_git_failure(
277
- tmp_path: Path,
278
- capsys: pytest.CaptureFixture[str],
279
- ) -> None:
280
- """git diff --name-status failure must surface as SystemExit + stderr."""
281
- failing_completed = unittest.mock.MagicMock()
282
- failing_completed.returncode = 128
283
- failing_completed.stdout = ""
284
- failing_completed.stderr = "fatal: not a git repository\n"
285
- with unittest.mock.patch("subprocess.run", return_value=failing_completed):
286
- with pytest.raises(SystemExit):
287
- gate_module.is_staged_file_newly_added(tmp_path, "anything.py")
288
- captured = capsys.readouterr()
289
- assert "git diff --cached --name-status" in captured.err
290
-
291
-
292
- def test_whole_file_line_set_raises_system_exit_on_oserror(
293
- tmp_path: Path,
294
- capsys: pytest.CaptureFixture[str],
295
- ) -> None:
296
- """OSError reading a file must propagate as SystemExit, not silently return ``set()``.
297
-
298
- Regression for loop1-7: returning an empty set on OSError caused the gate
299
- to route every violation to the advisory bucket and exit 0 — silently
300
- downgrading blocking violations to non-blocking on a read failure.
301
- """
302
- unreadable_path = tmp_path / "broken.py"
303
- with unittest.mock.patch.object(
304
- Path, "read_text", side_effect=PermissionError("denied")
305
- ):
306
- with pytest.raises(SystemExit):
307
- gate_module.whole_file_line_set(unreadable_path)
308
- captured = capsys.readouterr()
309
- assert str(unreadable_path) in captured.err
310
- assert "denied" in captured.err or "PermissionError" in captured.err
311
-
312
-
313
- def test_whole_file_line_set_raises_system_exit_on_non_utf8_file(
314
- tmp_path: Path,
315
- capsys: pytest.CaptureFixture[str],
316
- ) -> None:
317
- """A genuine non-UTF-8 file must fail closed as SystemExit, never crash.
318
-
319
- ``read_text(encoding="utf-8")`` on undecodable bytes raises
320
- UnicodeDecodeError, a ValueError subclass that ``OSError`` does not catch.
321
- The fail-closed contract that holds for read failures holds equally here:
322
- returning an empty set would route every violation to the advisory bucket,
323
- so an undecodable file must propagate as SystemExit rather than escape as
324
- an unhandled UnicodeDecodeError.
325
- """
326
- non_utf8_path = tmp_path / "garbled.py"
327
- non_utf8_path.write_bytes(b"\xff\xfe\x00bad")
328
- with pytest.raises(SystemExit):
329
- gate_module.whole_file_line_set(non_utf8_path)
330
- captured = capsys.readouterr()
331
- assert str(non_utf8_path) in captured.err
332
-
333
-
334
- def test_check_database_column_string_magic_signals_cap_exit(
335
- capsys: pytest.CaptureFixture[str],
336
- ) -> None:
337
- """When the issue cap is hit, a 'cap reached' note must reach stderr."""
338
- source_with_many_column_tuples = "\n".join(
339
- [
340
- "def write_rows():",
341
- " rows = [",
342
- *[
343
- f" ('column_name_{each_index}', {each_index}),"
344
- for each_index in range(10)
345
- ],
346
- " ]",
347
- " return rows",
348
- ]
349
- )
350
- issues = gate_module.check_database_column_string_magic(
351
- source_with_many_column_tuples,
352
- "production/file.py",
353
- )
354
- assert len(issues) == 3
355
- captured = capsys.readouterr()
356
- assert "cap reached" in captured.err.lower()
357
-
358
-
359
- def test_check_wrapper_plumb_through_caps_findings_at_max_per_check() -> None:
360
- """check_wrapper_plumb_through stops emitting at MAX_VIOLATIONS_PER_CHECK
361
- findings — the cap bounds the blocking payload silently, matching the
362
- _shared gate copy."""
363
- delegate_definition = (
364
- "def delegate(*, optional_one=1, optional_two=2, optional_three=3,"
365
- " optional_four=4): return 0\n"
366
- )
367
- wrappers_block = "\n".join(
368
- f"def wrapper_{each_index}():\n return self.delegate()"
369
- for each_index in range(5)
370
- )
371
- source_with_many_wrappers = delegate_definition + wrappers_block + "\n"
372
- issues = gate_module.check_wrapper_plumb_through(
373
- source_with_many_wrappers,
374
- "production/wrappers.py",
375
- )
376
- assert len(issues) == gate_module.MAX_VIOLATIONS_PER_CHECK
377
-
378
-
379
- def _bugteam_banned_noun_parameter_issues() -> list[str]:
380
- validate_content = gate_module.load_validate_content()
381
- source = (
382
- "def aggregate(canned_results: int) -> int:\n"
383
- " doubled = canned_results * 2\n"
384
- " return doubled\n"
385
- )
386
- issues = validate_content(source, "src/module.py", "")
387
- return [each_issue for each_issue in issues if "banned noun" in each_issue]
388
-
389
-
390
- def test_bugteam_split_violations_blocks_banned_noun_when_binding_line_is_added() -> None:
391
- """The bugteam gate reconstructs a banned-noun binding's one-line span the
392
- same way the _shared gate does: the violation is blocking when its own
393
- binding line is among the added lines."""
394
- banned_noun_issues = _bugteam_banned_noun_parameter_issues()
395
- assert banned_noun_issues, "expected a banned-noun parameter issue"
396
- parameter_binding_line = 1
397
- blocking, advisory = gate_module.split_violations_by_scope(
398
- banned_noun_issues,
399
- {parameter_binding_line},
400
- )
401
- assert blocking == banned_noun_issues
402
- assert advisory == []
403
-
404
-
405
- def test_bugteam_split_violations_advises_banned_noun_when_binding_line_untouched() -> None:
406
- """A banned-noun binding whose own line is untouched is advisory at the
407
- bugteam gate, so editing an unrelated body line does not pull a pre-existing
408
- binding into scope."""
409
- banned_noun_issues = _bugteam_banned_noun_parameter_issues()
410
- assert banned_noun_issues, "expected a banned-noun parameter issue"
411
- unrelated_body_line = 2
412
- blocking, advisory = gate_module.split_violations_by_scope(
413
- banned_noun_issues,
414
- {unrelated_body_line},
415
- )
416
- assert advisory == banned_noun_issues
417
- assert blocking == []
418
-
419
-
420
- def test_bugteam_banned_noun_span_range_covers_only_the_binding_line() -> None:
421
- """The reconstructed span is the binding line alone — one line, never the
422
- enclosing function span. A parameter declared on a ``def`` line yields a
423
- range covering only that line, so an unrelated body edit cannot pull the
424
- pre-existing binding into scope."""
425
- banned_noun_issues = _bugteam_banned_noun_parameter_issues()
426
- assert banned_noun_issues, "expected a banned-noun parameter issue"
427
- parameter_binding_line = 1
428
- span = gate_module.banned_noun_span_range(banned_noun_issues[0])
429
- assert span == range(parameter_binding_line, parameter_binding_line + 1)
430
- assert len(span) == 1
431
-
432
-
433
- def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
434
- tmp_path: Path,
435
- capsys: pytest.CaptureFixture[str],
436
- ) -> None:
437
- """Skipping an unreadable file during run_gate must cause a non-zero exit."""
438
- target_file = tmp_path / "sample.py"
439
- target_file.write_text("clean = 1\n", encoding="utf-8")
440
-
441
- def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
442
- return []
443
-
444
- with unittest.mock.patch.object(
445
- Path, "read_text", side_effect=PermissionError("denied")
446
- ):
447
- exit_code = gate_module.run_gate(
448
- fake_validate,
449
- [target_file],
450
- tmp_path,
451
- all_added_lines_map=None,
452
- )
453
- captured = capsys.readouterr()
454
- assert exit_code != 0, (
455
- "Files skipped due to read errors must produce a non-zero gate exit"
456
- )
457
- assert "skip unreadable" in captured.err
458
-
459
-
460
- def test_run_gate_skips_non_utf8_file_without_crashing(
461
- tmp_path: Path,
462
- capsys: pytest.CaptureFixture[str],
463
- ) -> None:
464
- """A non-UTF-8 code file is skipped, not crashed on, and forces a non-zero exit."""
465
- non_utf8_file = tmp_path / "garbled.py"
466
- non_utf8_file.write_bytes(b"\xff\xfe\x00bad bytes")
467
-
468
- def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
469
- return []
470
-
471
- exit_code = gate_module.run_gate(
472
- fake_validate,
473
- [non_utf8_file],
474
- tmp_path,
475
- all_added_lines_map=None,
476
- )
477
-
478
- captured = capsys.readouterr()
479
- assert exit_code != 0, (
480
- "A file skipped for non-UTF-8 content must produce a non-zero gate exit"
481
- )
482
- assert "skip unreadable" in captured.err
483
-
484
-
485
- def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
486
- temporary_git_repository: Path,
487
- monkeypatch: pytest.MonkeyPatch,
488
- ) -> None:
489
- write_file(
490
- temporary_git_repository / "sample.py",
491
- "alpha = 1\nbeta = 2\n",
492
- )
493
- commit_all_files(temporary_git_repository, "baseline")
494
- write_file(temporary_git_repository / "sample.py", "alpha = 1\nbeta = 2\ngamma = 3\n")
495
- stage_file(temporary_git_repository, "sample.py")
496
-
497
- monkeypatch.setattr(gate_module, "parse_added_line_numbers", lambda _text: set())
498
-
499
- added_line_numbers = gate_module.added_lines_for_staged_file(
500
- temporary_git_repository,
501
- "sample.py",
502
- )
503
-
504
- assert added_line_numbers == set()
505
-
1
+ """Smoke tests for the skill-path code_rules_gate wrap.
506
2
 
507
- def _build_function_module(
508
- function_name: str, body_line_count: int, leading_lines: int
509
- ) -> str:
510
- preamble = "".join("anchor_name\n" for _ in range(leading_lines))
511
- body = "\n".join(" keep_alive_name" for _ in range(body_line_count))
512
- return f"{preamble}def {function_name}() -> None:\n{body}\n"
513
-
514
-
515
- def test_split_violations_blocks_function_length_when_span_intersects_added_lines() -> None:
516
- """A function-length issue whose declared span overlaps the diff's added
517
- lines is blocking — the body grew, which is the regression intent."""
518
- validate_content = gate_module.load_validate_content()
519
- long_function = _build_function_module(
520
- "oversized", body_line_count=70, leading_lines=3
521
- )
522
- issues = validate_content(long_function, "src/long_module.py", "")
523
- function_length_issues = [
524
- each_issue for each_issue in issues if "blocking threshold" in each_issue
525
- ]
526
- assert function_length_issues, f"expected a function-length issue, got {issues!r}"
527
- span_def_line = 4
528
- inside_span_line = span_def_line + 10
529
- blocking, advisory = gate_module.split_violations_by_scope(
530
- function_length_issues,
531
- all_added_line_numbers={inside_span_line},
532
- )
533
- assert blocking == function_length_issues
534
- assert advisory == []
535
-
536
-
537
- def test_split_violations_advises_function_length_when_span_misses_added_lines() -> None:
538
- """A function-length issue for an untouched pre-existing function — whose
539
- declared span does not overlap any added line — is advisory, not blocking.
540
- Prevents the over-block regression where every pre-existing long function
541
- in a touched file was forced into the blocking payload."""
542
- validate_content = gate_module.load_validate_content()
543
- long_function = _build_function_module(
544
- "oversized", body_line_count=70, leading_lines=3
545
- )
546
- issues = validate_content(long_function, "src/long_module.py", "")
547
- function_length_issues = [
548
- each_issue for each_issue in issues if "blocking threshold" in each_issue
549
- ]
550
- assert function_length_issues, f"expected a function-length issue, got {issues!r}"
551
- line_far_outside_span = 5000
552
- blocking, advisory = gate_module.split_violations_by_scope(
553
- function_length_issues,
554
- all_added_line_numbers={line_far_outside_span},
555
- )
556
- assert advisory == function_length_issues
557
- assert blocking == []
558
-
559
-
560
- def _isolation_issues_for_home_probe_test() -> list[str]:
561
- validate_content = gate_module.load_validate_content()
562
- header = "from pathlib import Path\n"
563
- test_body = (
564
- "def test_reads_home() -> None:\n"
565
- " target_path = Path.home()\n"
566
- " assert target_path\n"
567
- )
568
- issues = validate_content(header + test_body, "src/test_module.py", "")
569
- return [each_issue for each_issue in issues if "probes" in each_issue]
570
-
571
-
572
- def test_split_violations_blocks_isolation_when_function_span_intersects_added_lines() -> None:
573
- """An isolation issue whose enclosing test-function span overlaps the diff's
574
- added lines is blocking — a signature-line change that un-isolates an
575
- unchanged-body probe must block, matching the enforcer's terminal path."""
576
- isolation_issues = _isolation_issues_for_home_probe_test()
577
- assert isolation_issues, "expected an isolation issue from the HOME probe test"
578
- signature_line = 2
579
- blocking, advisory = gate_module.split_violations_by_scope(
580
- isolation_issues,
581
- all_added_line_numbers={signature_line},
582
- )
583
- assert blocking == isolation_issues
584
- assert advisory == []
585
-
586
-
587
- def test_split_violations_advises_isolation_when_function_span_misses_added_lines() -> None:
588
- """An isolation issue for an untouched pre-existing probe — whose enclosing
589
- test-function span does not overlap any added line — is advisory, not
590
- blocking, mirroring the function-length scope contract."""
591
- isolation_issues = _isolation_issues_for_home_probe_test()
592
- assert isolation_issues, "expected an isolation issue from the HOME probe test"
593
- line_far_outside_span = 5000
594
- blocking, advisory = gate_module.split_violations_by_scope(
595
- isolation_issues,
596
- all_added_line_numbers={line_far_outside_span},
597
- )
598
- assert advisory == isolation_issues
599
- assert blocking == []
600
-
601
-
602
- def _oversized_function_text(function_name: str) -> str:
603
- body = "\n".join(" keep_alive_name" for _ in range(70))
604
- return f"def {function_name}() -> None:\n{body}\n"
605
-
606
-
607
- def _short_function_text(function_name: str) -> str:
608
- return f"def {function_name}() -> None:\n keep_alive_name\n"
609
-
610
-
611
- def test_main_blocks_sixth_long_function_on_added_lines_past_document_order(
612
- temporary_git_repository: Path,
613
- monkeypatch: pytest.MonkeyPatch,
614
- ) -> None:
615
- """bugbot-2: with five pre-existing untouched long functions ahead of it in
616
- document order, growing the sixth function past the threshold on staged
617
- lines must still block at the bugteam gate. The gate scopes by added lines,
618
- so the in-scope sixth violation blocks regardless of how many untouched
619
- ones precede it."""
620
- leading_long_functions = "".join(
621
- _oversized_function_text(f"leading_long_{each_index}")
622
- for each_index in range(5)
623
- )
624
- baseline = leading_long_functions + _short_function_text("target_function")
625
- write_file(temporary_git_repository / "module.py", baseline)
626
- commit_all_files(temporary_git_repository, "five long functions plus a short sixth")
627
-
628
- grown = leading_long_functions + _oversized_function_text("target_function")
629
- write_file(temporary_git_repository / "module.py", grown)
630
- stage_file(temporary_git_repository, "module.py")
631
-
632
- monkeypatch.chdir(temporary_git_repository)
633
- exit_code = gate_module.main(["--staged"])
634
-
635
- assert exit_code == 1, (
636
- "the sixth long function — the only one on staged lines — must block "
637
- "even though five untouched long functions precede it in document order"
638
- )
639
-
640
-
641
- def _home_probe_test_text(test_name: str) -> str:
642
- return (
643
- f"def {test_name}() -> None:\n"
644
- " target_path = Path.home()\n"
645
- " assert target_path\n"
646
- )
647
-
648
-
649
- def _clean_test_text(test_name: str) -> str:
650
- return f"def {test_name}() -> None:\n assert 1 + 1 == 2\n"
651
-
652
-
653
- def test_main_blocks_sixth_isolation_probe_on_added_lines_past_document_order(
654
- temporary_git_repository: Path,
655
- monkeypatch: pytest.MonkeyPatch,
656
- ) -> None:
657
- """bugbot-2 mirror: with five pre-existing untouched HOME probes ahead of it
658
- in document order, adding a HOME probe to the sixth test on staged lines
659
- must still block at the bugteam gate. The gate scopes by added lines, so the
660
- in-scope sixth probe blocks regardless of how many untouched ones precede
661
- it."""
662
- header = "from pathlib import Path\n"
663
- leading_probe_tests = "".join(
664
- _home_probe_test_text(f"test_leading_probe_{each_index}")
665
- for each_index in range(5)
666
- )
667
- baseline = header + leading_probe_tests + _clean_test_text("test_target_probe")
668
- write_file(temporary_git_repository / "test_module.py", baseline)
669
- commit_all_files(temporary_git_repository, "five probe tests plus a clean sixth")
670
-
671
- grown = header + leading_probe_tests + _home_probe_test_text("test_target_probe")
672
- write_file(temporary_git_repository / "test_module.py", grown)
673
- stage_file(temporary_git_repository, "test_module.py")
674
-
675
- monkeypatch.chdir(temporary_git_repository)
676
- exit_code = gate_module.main(["--staged"])
677
-
678
- assert exit_code == 1, (
679
- "the sixth HOME probe — the only one on staged lines — must block even "
680
- "though five untouched probes precede it in document order"
681
- )
3
+ Behavioral coverage lives at
4
+ ``_shared/pr-loop/scripts/tests/test_code_rules_gate.py``.
5
+ """
682
6
 
7
+ from __future__ import annotations
683
8
 
684
- def _banned_noun_function_text(index: int) -> str:
685
- return (
686
- f"def leading_{index}(canned_results: int) -> int:\n"
687
- f" return canned_results\n"
688
- )
9
+ import importlib.util
10
+ from pathlib import Path
11
+ from types import ModuleType
689
12
 
690
13
 
691
- def test_main_blocks_banned_noun_on_added_lines_past_document_order(
692
- temporary_git_repository: Path,
693
- monkeypatch: pytest.MonkeyPatch,
694
- ) -> None:
695
- """loop7-P1: with three pre-existing untouched banned-noun identifiers ahead
696
- of it in document order, introducing a fourth banned-noun on a staged line
697
- must still block at the bugteam gate. The gate scopes by added lines, so the
698
- in-scope identifier blocks regardless of how many untouched ones precede
699
- it."""
700
- leading_count = 3
701
- leading_functions = "".join(
702
- _banned_noun_function_text(each_index) for each_index in range(leading_count)
14
+ def _load_wrap_module() -> ModuleType:
15
+ module_path = Path(__file__).parent / "bugteam_code_rules_gate.py"
16
+ specification = importlib.util.spec_from_file_location(
17
+ "bugteam_code_rules_gate",
18
+ module_path,
703
19
  )
704
- baseline = leading_functions + "def placeholder() -> int:\n return 0\n"
705
- write_file(temporary_git_repository / "module.py", baseline)
706
- commit_all_files(temporary_git_repository, "three banned nouns plus a clean function")
707
-
708
- grown = leading_functions + "def aggregate(holiday_result: int) -> int:\n return holiday_result\n"
709
- write_file(temporary_git_repository / "module.py", grown)
710
- stage_file(temporary_git_repository, "module.py")
20
+ assert specification is not None
21
+ assert specification.loader is not None
22
+ wrap_module = importlib.util.module_from_spec(specification)
23
+ specification.loader.exec_module(wrap_module)
24
+ return wrap_module
711
25
 
712
- monkeypatch.chdir(temporary_git_repository)
713
- exit_code = gate_module.main(["--staged"])
714
26
 
715
- assert exit_code == 1, (
716
- "the fourth banned-noun identifier — the only one on staged lines — must "
717
- "block even though three untouched ones precede it in document order"
27
+ def test_wrap_main_passes_clean_python_file(tmp_path: Path) -> None:
28
+ clean_module_path = tmp_path / "clean_module.py"
29
+ clean_module_path.write_text(
30
+ "def greet() -> str:\n return 'hi'\n",
31
+ encoding="utf-8",
718
32
  )
719
-
720
-
721
- def test_report_partitioned_violations_returns_zero_when_clean(tmp_path: Path) -> None:
722
- """No blocking violations and no skipped files yields a zero exit code."""
723
- exit_code = gate_module._report_partitioned_violations(
724
- blocking_by_file={},
725
- advisory_by_file={tmp_path / "a.py": ["Line 1: advisory only"]},
726
- repository_root=tmp_path,
727
- is_whole_file_scope=False,
728
- skipped_unreadable_count=0,
33
+ wrap_module = _load_wrap_module()
34
+ exit_code = wrap_module.main(
35
+ ["--repo-root", str(tmp_path), str(clean_module_path.name)],
729
36
  )
730
37
  assert exit_code == 0
731
38
 
732
39
 
733
- def test_report_partitioned_violations_returns_one_on_blocking(tmp_path: Path) -> None:
734
- """A blocking violation yields a non-zero exit code."""
735
- exit_code = gate_module._report_partitioned_violations(
736
- blocking_by_file={tmp_path / "a.py": ["Line 1: blocking violation"]},
737
- advisory_by_file={},
738
- repository_root=tmp_path,
739
- is_whole_file_scope=False,
740
- skipped_unreadable_count=0,
741
- )
742
- assert exit_code == 1
743
-
744
-
745
- def test_report_partitioned_violations_returns_one_when_file_skipped(tmp_path: Path) -> None:
746
- """A skipped unreadable file forces a non-zero exit even with no blocking
747
- violations, because the gate cannot vouch for the file it could not read."""
748
- exit_code = gate_module._report_partitioned_violations(
749
- blocking_by_file={},
750
- advisory_by_file={},
751
- repository_root=tmp_path,
752
- is_whole_file_scope=False,
753
- skipped_unreadable_count=1,
754
- )
755
- assert exit_code == 1
756
-
757
-
758
- def test_check_wrapper_plumb_through_skips_class_methods_calling_module_delegate() -> None:
759
- """A class method calling a module-level delegate is not a wrapper; its
760
- signature is unrelated to the delegate's keyword surface, so it must not be
761
- flagged — matching the _shared gate copy."""
762
- source = (
763
- "def fetch(target, *, retries=3):\n"
764
- " return target\n"
765
- "\n"
766
- "class MyService:\n"
767
- " def public_method(self, target):\n"
768
- " return fetch(target)\n"
769
- )
770
- issues = gate_module.check_wrapper_plumb_through(source, "module.py")
771
- assert issues == [], (
772
- f"class methods must not be treated as module-level wrappers; got {issues!r}"
773
- )
774
-
775
-
776
- def test_check_wrapper_plumb_through_flags_name_call_dropping_kwarg() -> None:
777
- """A bare-name call (``delegate(value)``) to a same-file delegate that
778
- exposes an optional kwarg the public wrapper omits must be flagged — the
779
- bugteam copy must handle ``ast.Name`` targets, not only ``ast.Attribute``."""
780
- source = (
781
- "def delegate(value, *, retries=3):\n"
782
- " return value\n"
783
- "\n"
784
- "def public_wrapper(value):\n"
785
- " return delegate(value)\n"
786
- )
787
- issues = gate_module.check_wrapper_plumb_through(source, "module.py")
788
- assert any("retries" in each_issue for each_issue in issues), (
789
- f"a bare-name delegate call dropping an optional kwarg must flag; got {issues!r}"
790
- )
791
-
792
-
793
- def test_check_wrapper_plumb_through_exempts_test_files() -> None:
794
- """A test_* function in a test-file path that calls a module-level helper
795
- exposing an optional kwarg is an ordinary pytest case, not a wrapper; the
796
- bugteam gate must exempt test files and emit zero findings."""
797
- source = (
798
- "def _helper(name, *, clean_name=None):\n"
799
- " return (name, clean_name)\n"
800
- "\n"
801
- "def test_uses_helper():\n"
802
- " return _helper('a', clean_name='b')\n"
803
- )
804
- issues = gate_module.check_wrapper_plumb_through(source, "pkg/test_thing.py")
805
- assert issues == [], (
806
- f"a wrapper shape in a test file must yield no findings; got {issues!r}"
807
- )
808
-
809
-
810
- def test_check_wrapper_plumb_through_still_flags_non_test_path_with_test_shape() -> None:
811
- """The test-file exemption is scoped to test paths only; the same wrapper
812
- shape on a non-test path must still be flagged."""
813
- source = (
814
- "def _helper(name, *, clean_name=None):\n"
815
- " return (name, clean_name)\n"
816
- "\n"
817
- "def test_uses_helper():\n"
818
- " return _helper('a', clean_name='b')\n"
819
- )
820
- issues = gate_module.check_wrapper_plumb_through(source, "pkg/module.py")
821
- assert any(
822
- "test_uses_helper" in each_issue and "clean_name" in each_issue
823
- for each_issue in issues
824
- ), f"the same wrapper shape on a non-test path must still flag; got {issues!r}"
825
-
826
-
827
- def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
828
- """A callee nested as an argument (``delegate(helper(x))``) is not a
829
- separate call site; only the enclosing call is inspected, matching the
830
- _shared gate copy."""
831
- source = (
832
- "def delegate(value, *, retries=3):\n"
833
- " return value\n"
834
- "\n"
835
- "def helper(value):\n"
836
- " return value\n"
837
- "\n"
838
- "def public_caller(value):\n"
839
- " return delegate(helper(value))\n"
840
- )
841
- issues = gate_module.check_wrapper_plumb_through(source, "module.py")
842
- assert all("helper" not in each_issue for each_issue in issues), (
843
- f"nested-argument callee must not be a separate call site; got {issues!r}"
844
- )
845
-
846
-
847
- def test_main_staged_mode_blocks_newly_staged_inline_comment(
848
- temporary_git_repository: Path,
849
- monkeypatch: pytest.MonkeyPatch,
850
- ) -> None:
851
- """A NEWLY STAGED inline comment must be detected as a new comment in
852
- ``--staged`` mode. This proves the gate passes the HEAD-committed content as
853
- ``old_content`` to the comparison validators (the prior base), not the
854
- current working-tree content. When the current file content is reused as
855
- ``old_content``, ``check_comment_changes`` sees identical old/new text and
856
- misses the staged comment entirely, so the gate exits 0 — the regression."""
857
- write_file(
858
- temporary_git_repository / "module.py",
859
- 'def describe_state() -> str:\n return "ready"\n',
860
- )
861
- commit_all_files(temporary_git_repository, "initial without comment")
862
- staged_content_with_new_comment = (
863
- "def describe_state() -> str:\n"
864
- ' label = "ready" # newly staged inline comment\n'
865
- " return label\n"
866
- )
867
- write_file(
868
- temporary_git_repository / "module.py",
869
- staged_content_with_new_comment,
870
- )
871
- stage_file(temporary_git_repository, "module.py")
872
-
873
- monkeypatch.chdir(temporary_git_repository)
874
- exit_code = gate_module.main(["--staged"])
875
-
876
- assert exit_code == 1, (
877
- "a newly staged inline comment must block; if it does not, the gate is "
878
- "passing the current file content as old_content instead of the "
879
- "HEAD-committed prior base, so check_comment_changes sees identical "
880
- "old/new text and misses the staged comment"
881
- )
882
-
883
-
884
- def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
885
- """check_wrapper_plumb_through must stay under the enforcer's function-length
886
- blocking threshold so its signature-index, class-method-id, and per-wrapper
887
- finding logic live in extracted helpers, matching the _shared gate copy."""
888
- declared_line_count = len(
889
- inspect.getsource(gate_module.check_wrapper_plumb_through).splitlines()
890
- )
891
- blocking_threshold = 60
892
- assert declared_line_count < blocking_threshold, (
893
- f"check_wrapper_plumb_through is {declared_line_count} lines; extract "
894
- "helpers to keep it under the function-length blocking threshold"
895
- )
896
-
897
-
898
- def test_read_prior_committed_content_returns_head_content_for_tracked_path(
899
- temporary_git_repository: Path,
900
- ) -> None:
901
- """A tracked path returns its HEAD-committed content, not the working copy."""
902
- committed_text = "alpha = 1\nbeta = 2\n"
903
- write_file(temporary_git_repository / "tracked.py", committed_text)
904
- commit_all_files(temporary_git_repository, "commit tracked file")
905
- write_file(
906
- temporary_git_repository / "tracked.py",
907
- committed_text + "gamma = 3\n",
908
- )
909
-
910
- prior_content = gate_module.read_prior_committed_content(
911
- temporary_git_repository.resolve(), "tracked.py"
912
- )
913
-
914
- assert prior_content == committed_text
915
-
916
-
917
- def test_read_prior_committed_content_returns_empty_for_untracked_path(
918
- temporary_git_repository: Path,
919
- ) -> None:
920
- """An untracked path yields an empty string because git show returns non-zero."""
921
- write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
922
- commit_all_files(temporary_git_repository, "anchor commit")
923
-
924
- prior_content = gate_module.read_prior_committed_content(
925
- temporary_git_repository.resolve(), "never_committed.py"
926
- )
927
-
928
- assert prior_content == ""
929
-
930
-
931
- def test_main_staged_mode_validates_staged_blob_not_working_tree(
932
- temporary_git_repository: Path,
933
- monkeypatch: pytest.MonkeyPatch,
934
- ) -> None:
935
- """Staged mode validates the staged blob, not the working tree.
936
-
937
- A blocking violation lives in the staged blob, but the working tree has
938
- been edited afterward to remove it. The gate must still block because it
939
- scopes added lines from the staged index and must read its content from
940
- the same staged source rather than the diverged working tree.
941
- """
942
- write_file(temporary_git_repository / "module.py", "first_count = 1\n")
943
- commit_all_files(temporary_git_repository, "initial")
944
- staged_content_with_banned_identifier = (
945
- "first_count = 1\n"
946
- "def compute_total(operand):\n"
947
- " result = operand + 1\n"
948
- " return result\n"
949
- )
950
- write_file(
951
- temporary_git_repository / "module.py",
952
- staged_content_with_banned_identifier,
953
- )
954
- stage_file(temporary_git_repository, "module.py")
955
- clean_working_tree_content = (
956
- "first_count = 1\n"
957
- "def compute_total(operand: int) -> int:\n"
958
- " return operand + 1\n"
959
- )
960
- write_file(
961
- temporary_git_repository / "module.py",
962
- clean_working_tree_content,
963
- )
964
-
965
- monkeypatch.chdir(temporary_git_repository)
966
- exit_code = gate_module.main(["--staged"])
967
-
968
- assert exit_code == 1, (
969
- "the staged blob carries a blocking violation; the gate must block "
970
- "even though the working tree was edited clean afterward"
971
- )
972
-
973
-
974
- def test_main_staged_mode_blocks_when_staged_file_absent_from_working_tree(
975
- temporary_git_repository: Path,
976
- monkeypatch: pytest.MonkeyPatch,
977
- ) -> None:
978
- """A staged blocking violation must block even when the working tree file
979
- is gone. Staging a violating file and then deleting it from the working
980
- tree leaves the violation only in the staged blob; the gate must validate
981
- that blob rather than skip the path for failing a working-tree existence
982
- check."""
983
- write_file(temporary_git_repository / "baseline.py", "first_count = 1\n")
984
- commit_all_files(temporary_git_repository, "initial")
985
- staged_content_with_banned_identifier = (
986
- "def compute_total(operand):\n"
987
- " result = operand + 1\n"
988
- " return result\n"
989
- )
990
- write_file(
991
- temporary_git_repository / "module.py",
992
- staged_content_with_banned_identifier,
993
- )
994
- stage_file(temporary_git_repository, "module.py")
995
- (temporary_git_repository / "module.py").unlink()
996
-
997
- monkeypatch.chdir(temporary_git_repository)
998
- exit_code = gate_module.main(["--staged"])
999
-
1000
- assert exit_code == 1, (
1001
- "the staged blob carries a blocking violation; the gate must block "
1002
- "even though the file was deleted from the working tree after staging"
1003
- )
1004
-
1005
-
1006
- def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
1007
- temporary_git_repository: Path,
1008
- monkeypatch: pytest.MonkeyPatch,
1009
- ) -> None:
1010
- """A staged deletion is not in the index, so its staged blob cannot be read.
1011
- The gate must skip such a path cleanly rather than counting it as an
1012
- unreadable file and failing closed. With no other staged violation, the
1013
- gate must exit zero."""
1014
- write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
1015
- commit_all_files(temporary_git_repository, "initial")
1016
- run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
1017
-
1018
- monkeypatch.chdir(temporary_git_repository)
1019
- exit_code = gate_module.main(["--staged"])
1020
-
1021
- assert exit_code == 0, (
1022
- "a staged deletion has no staged blob; the gate must skip it cleanly "
1023
- "rather than fail closed as if the file were unreadable"
1024
- )
1025
-
1026
-
1027
- _INLINE_DUPLICATE_MESSAGE = (
1028
- "Function '_wait_for_render' duplicates an inline block in '_navigate_then_wait'"
1029
- " — this function body is also present inline (Reuse before create / DRY) "
1030
- "(inline duplicate body spans: helper at line 4 spanning 10 lines, "
1031
- "enclosing at line 16 spanning 11 lines)"
1032
- )
1033
-
1034
-
1035
- def test_inline_duplicate_body_span_lines_unions_helper_and_enclosing_spans() -> None:
1036
- """The same-file inline-duplicate message carries both spans, and the gate
1037
- recovers their union as a line-number set so a touch of either function blocks —
1038
- mirroring the live Write/Edit hook's union scoping."""
1039
- span_lines = gate_module.inline_duplicate_body_span_lines(_INLINE_DUPLICATE_MESSAGE)
1040
- assert span_lines == frozenset(range(4, 14)) | frozenset(range(16, 27))
1041
-
1042
-
1043
- def test_inline_duplicate_blocks_when_only_enclosing_copy_added() -> None:
1044
- """An added line in the enclosing span alone blocks, because the live hook scopes
1045
- by the union of both spans and blocks the same edit — the common shape where a
1046
- block is copied INTO a growing enclosing function, leaving the helper untouched."""
1047
- added_line_in_enclosing_only = 18
1048
- blocking, advisory = gate_module.split_violations_by_scope(
1049
- [_INLINE_DUPLICATE_MESSAGE],
1050
- all_added_line_numbers={added_line_in_enclosing_only},
1051
- )
1052
- assert blocking == [_INLINE_DUPLICATE_MESSAGE]
1053
- assert advisory == []
1054
-
1055
-
1056
- def test_inline_duplicate_advises_when_gap_line_added() -> None:
1057
- """An edit confined to the gap between the helper span (4-13) and the enclosing
1058
- span (16-26) must not block, matching the live hook. The union set keeps the gap
1059
- out of scope where a single contiguous range would wrongly block it."""
1060
- gap_line_between_spans = 14
1061
- blocking, advisory = gate_module.split_violations_by_scope(
1062
- [_INLINE_DUPLICATE_MESSAGE],
1063
- all_added_line_numbers={gap_line_between_spans},
1064
- )
1065
- assert advisory == [_INLINE_DUPLICATE_MESSAGE]
1066
- assert blocking == []
40
+ def test_wrap_main_help_exits_cleanly() -> None:
41
+ wrap_module = _load_wrap_module()
42
+ try:
43
+ wrap_module.main(["--help"])
44
+ except SystemExit as exit_signal:
45
+ assert exit_signal.code in (0, None)
46
+ return
47
+ raise AssertionError("main(['--help']) must raise SystemExit")