claude-dev-env 1.36.1 → 1.37.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/_shared/pr-loop/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +449 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- package/hooks/blocking/code_rules_enforcer.py +269 -23
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
- package/hooks/config/test_unused_module_import_constants.py +48 -0
- package/hooks/config/unused_module_import_constants.py +41 -0
- package/package.json +2 -1
- package/skills/bg-agent/SKILL.md +69 -0
- package/skills/bugteam/CONSTRAINTS.md +10 -19
- package/skills/bugteam/PROMPTS.md +3 -3
- package/skills/bugteam/SKILL.md +103 -202
- package/skills/bugteam/SKILL_EVALS.md +75 -114
- package/skills/bugteam/reference/README.md +2 -4
- package/skills/bugteam/reference/design-rationale.md +3 -8
- package/skills/bugteam/reference/team-setup.md +11 -19
- package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
- package/skills/bugteam/sources.md +1 -25
- package/skills/bugteam/test_skill_additions.py +4 -13
- package/skills/fresh-branch/SKILL.md +71 -0
- package/skills/gotcha/SKILL.md +73 -0
- package/skills/monitor-open-prs/SKILL.md +4 -37
- package/skills/monitor-open-prs/test_skill_contract.py +0 -5
- package/skills/pr-converge/SKILL.md +60 -1298
- package/skills/pr-converge/reference/convergence-gates.md +118 -0
- package/skills/pr-converge/reference/examples.md +76 -0
- package/skills/pr-converge/reference/fix-protocol.md +54 -0
- package/skills/pr-converge/reference/ground-rules.md +13 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
- package/skills/pr-converge/reference/per-tick.md +201 -0
- package/skills/pr-converge/reference/state-schema.md +19 -0
- package/skills/pr-converge/reference/stop-conditions.md +26 -0
- package/skills/pr-converge/scripts/README.md +36 -9
- package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
- package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
- package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
- package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
- package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
- package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
- package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
- package/skills/bugteam/test_team_lifecycle.py +0 -103
- package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
- package/skills/pr-converge/test_team_lifecycle.py +0 -56
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
"""Tests for shared code_rules_gate.py extracted from skills/bugteam/scripts/.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Module loads from _shared/pr-loop/scripts/ location
|
|
5
|
+
- resolve_claude_dev_env_root walks up to find code_rules_enforcer.py
|
|
6
|
+
- Path-resolution remains correct in both source layout and ~/.claude install layout
|
|
7
|
+
- Behavioral parity with the bugteam source: staged paths, added line maps, gate exit codes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import inspect
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import unittest.mock
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_gate_module() -> ModuleType:
|
|
22
|
+
module_path = Path(__file__).parent.parent / "code_rules_gate.py"
|
|
23
|
+
spec = importlib.util.spec_from_file_location("code_rules_gate", module_path)
|
|
24
|
+
assert spec is not None
|
|
25
|
+
assert spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
gate_module = _load_gate_module()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
|
|
35
|
+
completion = subprocess.run(
|
|
36
|
+
["git", *arguments],
|
|
37
|
+
cwd=str(repository_root),
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
check=True,
|
|
43
|
+
)
|
|
44
|
+
return completion.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def initialize_git_repository(repository_root: Path) -> None:
|
|
48
|
+
run_git_in_repository(repository_root, "init", "--initial-branch=main")
|
|
49
|
+
run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
|
|
50
|
+
run_git_in_repository(repository_root, "config", "user.name", "Test")
|
|
51
|
+
run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def commit_all_files(repository_root: Path, commit_message: str) -> None:
|
|
55
|
+
run_git_in_repository(repository_root, "add", "-A")
|
|
56
|
+
run_git_in_repository(repository_root, "commit", "-m", commit_message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_file(file_path: Path, content: str) -> None:
|
|
60
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
file_path.write_text(content, encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stage_file(repository_root: Path, relative_path: str) -> None:
|
|
65
|
+
run_git_in_repository(repository_root, "add", "--", relative_path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture()
|
|
69
|
+
def temporary_git_repository(tmp_path: Path) -> Path:
|
|
70
|
+
repository_root = tmp_path / "repository_under_test"
|
|
71
|
+
repository_root.mkdir()
|
|
72
|
+
initialize_git_repository(repository_root)
|
|
73
|
+
return repository_root
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_resolve_claude_dev_env_root_walks_up_to_find_enforcer(tmp_path: Path) -> None:
|
|
77
|
+
fake_root = tmp_path / "fake_claude"
|
|
78
|
+
enforcer_path = fake_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
79
|
+
enforcer_path.parent.mkdir(parents=True)
|
|
80
|
+
enforcer_path.write_text("# fake enforcer\n", encoding="utf-8")
|
|
81
|
+
deep_script = fake_root / "_shared" / "pr-loop" / "scripts" / "code_rules_gate.py"
|
|
82
|
+
deep_script.parent.mkdir(parents=True)
|
|
83
|
+
deep_script.write_text("# stub\n", encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
resolved_root = gate_module.resolve_claude_dev_env_root(deep_script)
|
|
86
|
+
|
|
87
|
+
assert resolved_root == fake_root.resolve()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_resolve_claude_dev_env_root_supports_legacy_skills_layout(
|
|
91
|
+
tmp_path: Path,
|
|
92
|
+
) -> None:
|
|
93
|
+
fake_root = tmp_path / "fake_dev_env"
|
|
94
|
+
enforcer_path = fake_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
95
|
+
enforcer_path.parent.mkdir(parents=True)
|
|
96
|
+
enforcer_path.write_text("# fake enforcer\n", encoding="utf-8")
|
|
97
|
+
legacy_script = fake_root / "skills" / "bugteam" / "scripts" / "code_rules_gate.py"
|
|
98
|
+
legacy_script.parent.mkdir(parents=True)
|
|
99
|
+
legacy_script.write_text("# stub\n", encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
resolved_root = gate_module.resolve_claude_dev_env_root(legacy_script)
|
|
102
|
+
|
|
103
|
+
assert resolved_root == fake_root.resolve()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_resolve_claude_dev_env_root_raises_when_enforcer_missing(
|
|
107
|
+
tmp_path: Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
deep_script = tmp_path / "_shared" / "pr-loop" / "scripts" / "code_rules_gate.py"
|
|
110
|
+
deep_script.parent.mkdir(parents=True)
|
|
111
|
+
deep_script.write_text("# stub\n", encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
with pytest.raises(SystemExit):
|
|
114
|
+
gate_module.resolve_claude_dev_env_root(deep_script)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_resolve_claude_dev_env_root_from_module_path_finds_real_enforcer() -> None:
|
|
118
|
+
module_path = Path(gate_module.__file__).resolve()
|
|
119
|
+
resolved_root = gate_module.resolve_claude_dev_env_root(module_path)
|
|
120
|
+
expected_enforcer = resolved_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
121
|
+
assert expected_enforcer.is_file()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_paths_from_git_staged_returns_staged_files(
|
|
125
|
+
temporary_git_repository: Path,
|
|
126
|
+
) -> None:
|
|
127
|
+
write_file(temporary_git_repository / "committed_file.py", "one = 1\n")
|
|
128
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
129
|
+
write_file(temporary_git_repository / "newly_staged_file.py", "two = 2\n")
|
|
130
|
+
write_file(temporary_git_repository / "unstaged_file.py", "three = 3\n")
|
|
131
|
+
stage_file(temporary_git_repository, "newly_staged_file.py")
|
|
132
|
+
|
|
133
|
+
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
134
|
+
|
|
135
|
+
staged_names = {path.name for path in staged_paths}
|
|
136
|
+
assert "newly_staged_file.py" in staged_names
|
|
137
|
+
assert "unstaged_file.py" not in staged_names
|
|
138
|
+
assert "committed_file.py" not in staged_names
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_added_lines_for_staged_file_reports_new_lines(
|
|
142
|
+
temporary_git_repository: Path,
|
|
143
|
+
) -> None:
|
|
144
|
+
write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
|
|
145
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
146
|
+
write_file(
|
|
147
|
+
temporary_git_repository / "target.py",
|
|
148
|
+
"first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
|
|
149
|
+
)
|
|
150
|
+
stage_file(temporary_git_repository, "target.py")
|
|
151
|
+
|
|
152
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
153
|
+
temporary_git_repository,
|
|
154
|
+
"target.py",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert 3 in added_line_numbers
|
|
158
|
+
assert 4 in added_line_numbers
|
|
159
|
+
assert 1 not in added_line_numbers
|
|
160
|
+
assert 2 not in added_line_numbers
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_added_lines_for_staged_file_treats_new_file_as_fully_added(
|
|
164
|
+
temporary_git_repository: Path,
|
|
165
|
+
) -> None:
|
|
166
|
+
write_file(temporary_git_repository / "existing.py", "ignored = 0\n")
|
|
167
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
168
|
+
write_file(
|
|
169
|
+
temporary_git_repository / "brand_new.py",
|
|
170
|
+
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
171
|
+
)
|
|
172
|
+
stage_file(temporary_git_repository, "brand_new.py")
|
|
173
|
+
|
|
174
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
175
|
+
temporary_git_repository,
|
|
176
|
+
"brand_new.py",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert added_line_numbers == {1, 2, 3}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_paths_from_git_staged_uses_null_delimiter(
|
|
183
|
+
temporary_git_repository: Path,
|
|
184
|
+
) -> None:
|
|
185
|
+
write_file(temporary_git_repository / "first.py", "a = 1\n")
|
|
186
|
+
write_file(temporary_git_repository / "second.py", "b = 2\n")
|
|
187
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
188
|
+
write_file(temporary_git_repository / "first.py", "a = 10\n")
|
|
189
|
+
write_file(temporary_git_repository / "second.py", "b = 20\n")
|
|
190
|
+
stage_file(temporary_git_repository, "first.py")
|
|
191
|
+
stage_file(temporary_git_repository, "second.py")
|
|
192
|
+
|
|
193
|
+
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
194
|
+
|
|
195
|
+
staged_names = {path.name for path in staged_paths}
|
|
196
|
+
assert staged_names == {"first.py", "second.py"}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_paths_from_git_staged_warns_and_skips_non_utf8_filename(
|
|
200
|
+
tmp_path: Path,
|
|
201
|
+
capsys: pytest.CaptureFixture[str],
|
|
202
|
+
) -> None:
|
|
203
|
+
non_utf8_raw = b"valid.py\x00\xff\xfe_bad.py\x00"
|
|
204
|
+
mock_completed = unittest.mock.MagicMock()
|
|
205
|
+
mock_completed.returncode = 0
|
|
206
|
+
mock_completed.stdout = non_utf8_raw
|
|
207
|
+
|
|
208
|
+
with unittest.mock.patch("subprocess.run", return_value=mock_completed):
|
|
209
|
+
result_paths = gate_module.paths_from_git_staged(tmp_path)
|
|
210
|
+
|
|
211
|
+
captured = capsys.readouterr()
|
|
212
|
+
assert "non-UTF-8" in captured.err
|
|
213
|
+
assert len(result_paths) == 1
|
|
214
|
+
assert result_paths[0].name == "valid.py"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_staged_added_lines_by_file_maps_every_staged_code_file(
|
|
218
|
+
temporary_git_repository: Path,
|
|
219
|
+
) -> None:
|
|
220
|
+
write_file(temporary_git_repository / "already_committed.py", "zero = 0\n")
|
|
221
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
222
|
+
write_file(
|
|
223
|
+
temporary_git_repository / "already_committed.py",
|
|
224
|
+
"zero = 0\nappended = 1\n",
|
|
225
|
+
)
|
|
226
|
+
write_file(temporary_git_repository / "added_file.py", "only = 1\n")
|
|
227
|
+
stage_file(temporary_git_repository, "already_committed.py")
|
|
228
|
+
stage_file(temporary_git_repository, "added_file.py")
|
|
229
|
+
|
|
230
|
+
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
231
|
+
added_lines_map = gate_module.added_lines_by_file_staged(
|
|
232
|
+
temporary_git_repository,
|
|
233
|
+
staged_paths,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
resolved_repository_root = temporary_git_repository.resolve()
|
|
237
|
+
assert added_lines_map[resolved_repository_root / "already_committed.py"] == {2}
|
|
238
|
+
assert added_lines_map[resolved_repository_root / "added_file.py"] == {1}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
|
|
242
|
+
temporary_git_repository: Path,
|
|
243
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
244
|
+
) -> None:
|
|
245
|
+
write_file(temporary_git_repository / "module.py", "first_value = 1\n")
|
|
246
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
247
|
+
staged_content_with_banned_identifier = (
|
|
248
|
+
"first_value = 1\n"
|
|
249
|
+
"def compute_total(operand):\n"
|
|
250
|
+
" result = operand + 1\n"
|
|
251
|
+
" return result\n"
|
|
252
|
+
)
|
|
253
|
+
write_file(
|
|
254
|
+
temporary_git_repository / "module.py",
|
|
255
|
+
staged_content_with_banned_identifier,
|
|
256
|
+
)
|
|
257
|
+
stage_file(temporary_git_repository, "module.py")
|
|
258
|
+
|
|
259
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
260
|
+
exit_code = gate_module.main(["--staged"])
|
|
261
|
+
|
|
262
|
+
assert exit_code == 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_main_staged_mode_passes_when_no_staged_violations(
|
|
266
|
+
temporary_git_repository: Path,
|
|
267
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
268
|
+
) -> None:
|
|
269
|
+
write_file(temporary_git_repository / "module.py", "first_value = 1\n")
|
|
270
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
271
|
+
write_file(
|
|
272
|
+
temporary_git_repository / "module.py", "first_value = 1\nsecond_value = 2\n"
|
|
273
|
+
)
|
|
274
|
+
stage_file(temporary_git_repository, "module.py")
|
|
275
|
+
|
|
276
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
277
|
+
exit_code = gate_module.main(["--staged"])
|
|
278
|
+
|
|
279
|
+
assert exit_code == 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_main_staged_mode_exits_zero_when_nothing_staged(
|
|
283
|
+
temporary_git_repository: Path,
|
|
284
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
285
|
+
) -> None:
|
|
286
|
+
write_file(temporary_git_repository / "module.py", "first_value = 1\n")
|
|
287
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
288
|
+
|
|
289
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
290
|
+
exit_code = gate_module.main(["--staged"])
|
|
291
|
+
|
|
292
|
+
assert exit_code == 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
|
|
296
|
+
temporary_git_repository: Path,
|
|
297
|
+
) -> None:
|
|
298
|
+
write_file(
|
|
299
|
+
temporary_git_repository / "existing.py",
|
|
300
|
+
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
301
|
+
)
|
|
302
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
303
|
+
write_file(temporary_git_repository / "existing.py", "alpha = 1\nbeta = 2\n")
|
|
304
|
+
stage_file(temporary_git_repository, "existing.py")
|
|
305
|
+
|
|
306
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
307
|
+
temporary_git_repository,
|
|
308
|
+
"existing.py",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
assert added_line_numbers == set()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_is_file_absent_in_index_head_does_not_exist_in_module() -> None:
|
|
315
|
+
assert not hasattr(gate_module, "is_file_absent_in_index_head")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
|
|
319
|
+
temporary_git_repository: Path,
|
|
320
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
321
|
+
) -> None:
|
|
322
|
+
write_file(
|
|
323
|
+
temporary_git_repository / "sample.py",
|
|
324
|
+
"alpha = 1\nbeta = 2\n",
|
|
325
|
+
)
|
|
326
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
327
|
+
write_file(
|
|
328
|
+
temporary_git_repository / "sample.py", "alpha = 1\nbeta = 2\ngamma = 3\n"
|
|
329
|
+
)
|
|
330
|
+
stage_file(temporary_git_repository, "sample.py")
|
|
331
|
+
|
|
332
|
+
monkeypatch.setattr(gate_module, "parse_added_line_numbers", lambda _text: set())
|
|
333
|
+
|
|
334
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
335
|
+
temporary_git_repository,
|
|
336
|
+
"sample.py",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
assert added_line_numbers == set()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_staged_file_line_count_escalates_on_git_failure(
|
|
343
|
+
tmp_path: Path,
|
|
344
|
+
capsys: pytest.CaptureFixture[str],
|
|
345
|
+
) -> None:
|
|
346
|
+
failing_completed = unittest.mock.MagicMock()
|
|
347
|
+
failing_completed.returncode = 128
|
|
348
|
+
failing_completed.stdout = ""
|
|
349
|
+
failing_completed.stderr = "fatal: bad object HEAD"
|
|
350
|
+
|
|
351
|
+
with unittest.mock.patch("subprocess.run", return_value=failing_completed):
|
|
352
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
353
|
+
gate_module.staged_file_line_count(tmp_path, "missing.py")
|
|
354
|
+
|
|
355
|
+
assert exit_info.value.code == 2
|
|
356
|
+
captured = capsys.readouterr()
|
|
357
|
+
assert "fatal: bad object HEAD" in captured.err
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_is_staged_file_newly_added_escalates_on_git_failure(
|
|
361
|
+
tmp_path: Path,
|
|
362
|
+
capsys: pytest.CaptureFixture[str],
|
|
363
|
+
) -> None:
|
|
364
|
+
failing_completed = unittest.mock.MagicMock()
|
|
365
|
+
failing_completed.returncode = 128
|
|
366
|
+
failing_completed.stdout = ""
|
|
367
|
+
failing_completed.stderr = "fatal: not a git repository"
|
|
368
|
+
|
|
369
|
+
with unittest.mock.patch("subprocess.run", return_value=failing_completed):
|
|
370
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
371
|
+
gate_module.is_staged_file_newly_added(tmp_path, "missing.py")
|
|
372
|
+
|
|
373
|
+
assert exit_info.value.code == 2
|
|
374
|
+
captured = capsys.readouterr()
|
|
375
|
+
assert "fatal: not a git repository" in captured.err
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_check_wrapper_plumb_through_flags_direct_same_file_call() -> None:
|
|
379
|
+
source = (
|
|
380
|
+
"def fetch(target, *, retries=3):\n"
|
|
381
|
+
" return target\n"
|
|
382
|
+
"\n"
|
|
383
|
+
"def public_fetch(target):\n"
|
|
384
|
+
" return fetch(target)\n"
|
|
385
|
+
)
|
|
386
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
387
|
+
assert any(
|
|
388
|
+
"public_fetch" in each_issue and "retries" in each_issue
|
|
389
|
+
for each_issue in issues
|
|
390
|
+
), (
|
|
391
|
+
"Direct same-file call (ast.Name) must be detected as a wrapper that "
|
|
392
|
+
"drops optional kwargs of its delegate"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_check_wrapper_plumb_through_still_flags_attribute_call() -> None:
|
|
397
|
+
source = (
|
|
398
|
+
"def fetch(target, *, retries=3):\n"
|
|
399
|
+
" return target\n"
|
|
400
|
+
"\n"
|
|
401
|
+
"def public_fetch(target):\n"
|
|
402
|
+
" return self.fetch(target)\n"
|
|
403
|
+
)
|
|
404
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
405
|
+
assert any(
|
|
406
|
+
"public_fetch" in each_issue and "retries" in each_issue
|
|
407
|
+
for each_issue in issues
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_split_violations_by_scope_accepts_all_added_line_numbers_param_name() -> None:
|
|
412
|
+
blocking_issues, advisory_issues = gate_module.split_violations_by_scope(
|
|
413
|
+
["Line 5: violation"],
|
|
414
|
+
all_added_line_numbers={5},
|
|
415
|
+
)
|
|
416
|
+
assert blocking_issues == ["Line 5: violation"]
|
|
417
|
+
assert advisory_issues == []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_run_gate_accepts_all_added_lines_by_path_param_name(tmp_path: Path) -> None:
|
|
421
|
+
gate_module.run_gate(
|
|
422
|
+
validate_content=lambda _content, _path, **_kwargs: [],
|
|
423
|
+
all_file_paths=[],
|
|
424
|
+
repository_root=tmp_path,
|
|
425
|
+
all_added_lines_by_path=None,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_whole_file_line_set_handles_non_cp1252_utf8(tmp_path: Path) -> None:
|
|
430
|
+
utf8_only_path = tmp_path / "utf8_only.py"
|
|
431
|
+
cp1252_invalid_codepoint = chr(0x81)
|
|
432
|
+
utf8_only_path.write_bytes(
|
|
433
|
+
f"control = '{cp1252_invalid_codepoint}'\nname = 'café'\n".encode("utf-8")
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
line_numbers = gate_module.whole_file_line_set(utf8_only_path)
|
|
437
|
+
|
|
438
|
+
assert line_numbers == {1, 2}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_run_gate_detects_new_inline_comment_in_touched_file(
|
|
442
|
+
temporary_git_repository: Path,
|
|
443
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Regression: run_gate must pass prior file content as old_content.
|
|
446
|
+
|
|
447
|
+
When old_content is incorrectly set to the new content, check_comment_changes
|
|
448
|
+
reads identical sets and never flags a newly added inline comment. This test
|
|
449
|
+
proves run_gate now reads the prior content from HEAD so newly added inline
|
|
450
|
+
comments in touched files surface as violations.
|
|
451
|
+
"""
|
|
452
|
+
write_file(
|
|
453
|
+
temporary_git_repository / "module.py",
|
|
454
|
+
"first_value = 1\nsecond_value = 2\n",
|
|
455
|
+
)
|
|
456
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
457
|
+
write_file(
|
|
458
|
+
temporary_git_repository / "module.py",
|
|
459
|
+
"first_value = 1\nsecond_value = 2 # added inline comment\n",
|
|
460
|
+
)
|
|
461
|
+
stage_file(temporary_git_repository, "module.py")
|
|
462
|
+
|
|
463
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
464
|
+
exit_code = gate_module.main(["--staged"])
|
|
465
|
+
|
|
466
|
+
assert exit_code == 1
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_run_gate_treats_new_files_prior_content_as_empty(
|
|
470
|
+
temporary_git_repository: Path,
|
|
471
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
472
|
+
) -> None:
|
|
473
|
+
write_file(temporary_git_repository / "existing.py", "alpha = 1\n")
|
|
474
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
475
|
+
write_file(
|
|
476
|
+
temporary_git_repository / "brand_new.py",
|
|
477
|
+
"first_value = 1\nsecond_value = 2 # comment in new file\n",
|
|
478
|
+
)
|
|
479
|
+
stage_file(temporary_git_repository, "brand_new.py")
|
|
480
|
+
|
|
481
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
482
|
+
exit_code = gate_module.main(["--staged"])
|
|
483
|
+
|
|
484
|
+
assert exit_code == 1
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_is_test_path_helper_matches_code_rules_patterns() -> None:
|
|
488
|
+
assert gate_module.is_test_path("packages/foo/test_bar.py")
|
|
489
|
+
assert gate_module.is_test_path("packages/foo/bar_test.py")
|
|
490
|
+
assert gate_module.is_test_path("packages/foo/bar.test.ts")
|
|
491
|
+
assert gate_module.is_test_path("packages/foo/bar.spec.js")
|
|
492
|
+
assert gate_module.is_test_path("packages/foo/conftest.py")
|
|
493
|
+
assert gate_module.is_test_path("packages/foo/tests/something.py")
|
|
494
|
+
assert not gate_module.is_test_path("packages/foo/regular_module.py")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_validate_content_callable_signature_is_explicit() -> None:
|
|
498
|
+
callable_alias_source = inspect.getsource(gate_module).split("\n")
|
|
499
|
+
matching_lines = [
|
|
500
|
+
each_line
|
|
501
|
+
for each_line in callable_alias_source
|
|
502
|
+
if "ValidateContentCallable" in each_line and "Callable[" in each_line
|
|
503
|
+
]
|
|
504
|
+
assert any("[str, str, str]" in each_line for each_line in matching_lines)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def test_run_gate_uses_each_path_loop_variable() -> None:
|
|
508
|
+
run_gate_source = inspect.getsource(gate_module.run_gate)
|
|
509
|
+
assert "for each_path in" in run_gate_source
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def test_run_gate_skips_non_utf8_source_without_crashing(
|
|
513
|
+
temporary_git_repository: Path,
|
|
514
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
515
|
+
) -> None:
|
|
516
|
+
"""Regression: run_gate must skip files that fail UTF-8 decoding.
|
|
517
|
+
|
|
518
|
+
UnicodeDecodeError is a ValueError subclass, not OSError. A non-UTF-8
|
|
519
|
+
source file in the staged set must be skipped (matching whole_file_line_set
|
|
520
|
+
behavior), not crash the gate mid-audit.
|
|
521
|
+
"""
|
|
522
|
+
write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
|
|
523
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
524
|
+
non_utf8_path = temporary_git_repository / "non_utf8.py"
|
|
525
|
+
non_utf8_path.parent.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
non_utf8_path.write_bytes(b"name = '\xff\xfe invalid utf8 bytes'\n")
|
|
527
|
+
stage_file(temporary_git_repository, "non_utf8.py")
|
|
528
|
+
|
|
529
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
530
|
+
exit_code = gate_module.main(["--staged"])
|
|
531
|
+
|
|
532
|
+
assert exit_code in {0, 1}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_check_wrapper_plumb_through_accepts_positional_or_keyword_forwarder() -> None:
|
|
536
|
+
"""Regression: positional-or-keyword forwarders with defaults must not be flagged.
|
|
537
|
+
|
|
538
|
+
When a wrapper exposes the delegate's optional kwarg as a positional-or-keyword
|
|
539
|
+
parameter with a default value and forwards it correctly, the check must produce
|
|
540
|
+
zero findings. This mirrors the live gh_util.fetch_inline_review_comments →
|
|
541
|
+
run_gh signature pairing on this PR.
|
|
542
|
+
"""
|
|
543
|
+
source = (
|
|
544
|
+
"def run_gh(all_command, *, timeout_seconds=30):\n"
|
|
545
|
+
" return all_command\n"
|
|
546
|
+
"\n"
|
|
547
|
+
"def fetch_inline_review_comments(owner, repo, pull_number, "
|
|
548
|
+
"timeout_seconds=30):\n"
|
|
549
|
+
" return run_gh(['gh'], timeout_seconds=timeout_seconds)\n"
|
|
550
|
+
)
|
|
551
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
552
|
+
assert issues == []
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_check_database_column_string_magic_dedupes_nested_function_tuples() -> None:
|
|
556
|
+
"""Regression: tuples inside nested FunctionDefs must produce one finding, not many.
|
|
557
|
+
|
|
558
|
+
The outer ast.walk previously enumerated every FunctionDef including nested
|
|
559
|
+
ones, then the inner ast.walk(each_node) walked the full subtree, so a tuple
|
|
560
|
+
inside a nested function was visited via every enclosing function. This must
|
|
561
|
+
surface exactly one finding per tuple site.
|
|
562
|
+
"""
|
|
563
|
+
source = (
|
|
564
|
+
"def outer():\n"
|
|
565
|
+
" def inner():\n"
|
|
566
|
+
' x = ("some_column_name", 42)\n'
|
|
567
|
+
" return x\n"
|
|
568
|
+
" return inner\n"
|
|
569
|
+
)
|
|
570
|
+
issues = gate_module.check_database_column_string_magic(source, "module.py")
|
|
571
|
+
assert len(issues) == 1, f"expected 1 finding, got {len(issues)}: {issues!r}"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_check_wrapper_plumb_through_skips_uppercase_js_extension() -> None:
|
|
575
|
+
"""Regression: case-insensitive filesystem (Windows, macOS) can yield
|
|
576
|
+
file paths like 'Foo.JS'. The skip predicate must normalize case so
|
|
577
|
+
files matching the non-Python extension set are skipped and never
|
|
578
|
+
fed to the Python AST analyzer.
|
|
579
|
+
"""
|
|
580
|
+
valid_python_with_wrapper_violation = (
|
|
581
|
+
"def fetch(target, *, retries=3):\n"
|
|
582
|
+
" return target\n"
|
|
583
|
+
"\n"
|
|
584
|
+
"def public_fetch(target):\n"
|
|
585
|
+
" return fetch(target)\n"
|
|
586
|
+
)
|
|
587
|
+
issues_for_uppercase_js = gate_module.check_wrapper_plumb_through(
|
|
588
|
+
valid_python_with_wrapper_violation, "Foo.JS"
|
|
589
|
+
)
|
|
590
|
+
issues_for_lowercase_js = gate_module.check_wrapper_plumb_through(
|
|
591
|
+
valid_python_with_wrapper_violation, "foo.js"
|
|
592
|
+
)
|
|
593
|
+
assert issues_for_uppercase_js == issues_for_lowercase_js
|
|
594
|
+
assert issues_for_uppercase_js == []
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_paths_from_git_diff_uses_null_delimiter(
|
|
598
|
+
tmp_path: Path,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Regression: paths_from_git_diff must use -z + null-byte split.
|
|
601
|
+
|
|
602
|
+
Mirrors paths_from_git_staged semantics so filenames containing newlines
|
|
603
|
+
or special characters are not silently mangled.
|
|
604
|
+
"""
|
|
605
|
+
null_terminated_stdout = b"first.py\x00second.py\x00name with\nnewline.py\x00"
|
|
606
|
+
mock_completed_run = unittest.mock.MagicMock()
|
|
607
|
+
mock_completed_run.returncode = 0
|
|
608
|
+
mock_completed_run.stdout = null_terminated_stdout
|
|
609
|
+
|
|
610
|
+
with unittest.mock.patch.object(
|
|
611
|
+
gate_module, "resolve_merge_base", return_value="abcdef0"
|
|
612
|
+
):
|
|
613
|
+
with unittest.mock.patch("subprocess.run", return_value=mock_completed_run) as mocked_run:
|
|
614
|
+
resolved_paths = gate_module.paths_from_git_diff(tmp_path, "origin/main")
|
|
615
|
+
|
|
616
|
+
invocation_arguments = mocked_run.call_args.args[0]
|
|
617
|
+
assert "-z" in invocation_arguments
|
|
618
|
+
|
|
619
|
+
resolved_names = {each_path.name for each_path in resolved_paths}
|
|
620
|
+
assert "first.py" in resolved_names
|
|
621
|
+
assert "second.py" in resolved_names
|
|
622
|
+
assert "name with\nnewline.py" in resolved_names
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_check_wrapper_plumb_through_dedupes_nested_public_function_calls() -> None:
|
|
626
|
+
"""Regression: delegate calls inside nested public functions must produce one finding.
|
|
627
|
+
|
|
628
|
+
Same nested-walk pathology as the column-magic check: a delegate call inside
|
|
629
|
+
a nested public function was flagged once for the inner FunctionDef and again
|
|
630
|
+
for any enclosing public FunctionDef. Apply consistent de-dup strategy.
|
|
631
|
+
"""
|
|
632
|
+
source = (
|
|
633
|
+
"def fetch(target, *, retries=3):\n"
|
|
634
|
+
" return target\n"
|
|
635
|
+
"\n"
|
|
636
|
+
"def public_outer():\n"
|
|
637
|
+
" def public_inner():\n"
|
|
638
|
+
" return fetch(target=None)\n"
|
|
639
|
+
" return public_inner\n"
|
|
640
|
+
)
|
|
641
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
642
|
+
assert len(issues) == 1, f"expected 1 finding, got {len(issues)}: {issues!r}"
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
|
|
648
|
+
"""Regression: nested callees inside another call's arguments are not wrapper sites.
|
|
649
|
+
|
|
650
|
+
Cursor Bugbot: `_iter_calls_excluding_nested_functions`
|
|
651
|
+
used to recurse into `ast.Call` children, so `delegate(helper(x))` yielded
|
|
652
|
+
both the outer `delegate` call and the inner `helper` call. The inner call
|
|
653
|
+
must not attribute dropped optional kwargs of `helper` to the enclosing
|
|
654
|
+
public function when `helper` is only a sub-expression argument.
|
|
655
|
+
"""
|
|
656
|
+
source = (
|
|
657
|
+
"def helper(x, *, opt=1):\n"
|
|
658
|
+
" return x\n"
|
|
659
|
+
"\n"
|
|
660
|
+
"def delegate(a):\n"
|
|
661
|
+
" return a\n"
|
|
662
|
+
"\n"
|
|
663
|
+
"def public_outer():\n"
|
|
664
|
+
" return delegate(helper(1))\n"
|
|
665
|
+
)
|
|
666
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
667
|
+
assert issues == [], (
|
|
668
|
+
"nested helper inside delegate arguments must not false-flag the outer "
|
|
669
|
+
f"public function; got {issues!r}"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def test_check_wrapper_plumb_through_ignores_calls_in_nested_functions() -> None:
|
|
674
|
+
"""Calls inside a nested FunctionDef must not be attributed to the outer function.
|
|
675
|
+
|
|
676
|
+
The outer public function does not call the delegate itself; only its
|
|
677
|
+
private nested helper does. Because the nested call lives in a separate
|
|
678
|
+
lexical scope, the outer must NOT be flagged for missing kwargs the inner
|
|
679
|
+
drops. Walking the outer with ast.walk would incorrectly descend into the
|
|
680
|
+
nested body and produce a false positive against the outer.
|
|
681
|
+
"""
|
|
682
|
+
source = (
|
|
683
|
+
"def fetch(target, *, retries=3):\n"
|
|
684
|
+
" return target\n"
|
|
685
|
+
"\n"
|
|
686
|
+
"def public_outer(target):\n"
|
|
687
|
+
" def _inner_helper():\n"
|
|
688
|
+
" return fetch(target)\n"
|
|
689
|
+
" return _inner_helper()\n"
|
|
690
|
+
)
|
|
691
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
692
|
+
assert issues == [], (
|
|
693
|
+
f"outer must not be flagged for kwargs dropped by a nested helper; got {issues!r}"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def test_check_wrapper_plumb_through_ignores_class_method_signatures() -> None:
|
|
698
|
+
"""Regression: methods sharing a name across classes must not collide.
|
|
699
|
+
|
|
700
|
+
`function_signatures` previously keyed on `each_node.name` regardless of
|
|
701
|
+
enclosing class, so `Foo.serialize(*, indent=2)` and `Bar.serialize(target)`
|
|
702
|
+
both became dict entry `"serialize"` and the second overwrote the first.
|
|
703
|
+
A module-level wrapper that calls `serialize(...)` was then incorrectly
|
|
704
|
+
matched against whichever class won the race. Restrict signature
|
|
705
|
+
collection to module-level functions so cross-class same-name methods
|
|
706
|
+
cannot pollute the wrapper-detection index.
|
|
707
|
+
"""
|
|
708
|
+
source = (
|
|
709
|
+
"class Foo:\n"
|
|
710
|
+
" def serialize(self, target, *, indent=2):\n"
|
|
711
|
+
" return target\n"
|
|
712
|
+
"\n"
|
|
713
|
+
"class Bar:\n"
|
|
714
|
+
" def serialize(self, target):\n"
|
|
715
|
+
" return target\n"
|
|
716
|
+
"\n"
|
|
717
|
+
"def public_serialize(target):\n"
|
|
718
|
+
" return serialize(target)\n"
|
|
719
|
+
)
|
|
720
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
721
|
+
assert issues == [], (
|
|
722
|
+
f"class-method same-name collision must not pollute the signature index; got {issues!r}"
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def test_added_lines_by_file_does_not_flag_pure_rename_as_whole_file_added(
|
|
727
|
+
temporary_git_repository: Path,
|
|
728
|
+
) -> None:
|
|
729
|
+
"""Regression: a file renamed without content edits must not appear as
|
|
730
|
+
a whole-file add. `git diff --unified=0 base..HEAD -- <newpath>` returns
|
|
731
|
+
an empty diff for the new path, and the absent-at-base check would
|
|
732
|
+
misclassify the renamed file as new and flag every line.
|
|
733
|
+
"""
|
|
734
|
+
write_file(
|
|
735
|
+
temporary_git_repository / "original_name.py",
|
|
736
|
+
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
737
|
+
)
|
|
738
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
739
|
+
run_git_in_repository(
|
|
740
|
+
temporary_git_repository,
|
|
741
|
+
"mv",
|
|
742
|
+
"original_name.py",
|
|
743
|
+
"new_name.py",
|
|
744
|
+
)
|
|
745
|
+
commit_all_files(temporary_git_repository, "rename only")
|
|
746
|
+
|
|
747
|
+
renamed_path = temporary_git_repository / "new_name.py"
|
|
748
|
+
added_lines_map = gate_module.added_lines_by_file(
|
|
749
|
+
temporary_git_repository,
|
|
750
|
+
"HEAD~1",
|
|
751
|
+
[renamed_path],
|
|
752
|
+
)
|
|
753
|
+
assert added_lines_map[renamed_path.resolve()] == set()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def test_renamed_file_source_map_since_maps_destination_to_source(
|
|
757
|
+
temporary_git_repository: Path,
|
|
758
|
+
) -> None:
|
|
759
|
+
"""renamed_file_source_map_since returns dest->source dict for renamed files."""
|
|
760
|
+
write_file(
|
|
761
|
+
temporary_git_repository / "source_file.py",
|
|
762
|
+
"x = 1\n",
|
|
763
|
+
)
|
|
764
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
765
|
+
run_git_in_repository(
|
|
766
|
+
temporary_git_repository,
|
|
767
|
+
"mv",
|
|
768
|
+
"source_file.py",
|
|
769
|
+
"dest_file.py",
|
|
770
|
+
)
|
|
771
|
+
commit_all_files(temporary_git_repository, "rename")
|
|
772
|
+
|
|
773
|
+
merge_base = gate_module.resolve_merge_base(temporary_git_repository, "HEAD~1")
|
|
774
|
+
rename_map = gate_module.renamed_file_source_map_since(
|
|
775
|
+
temporary_git_repository.resolve(), merge_base
|
|
776
|
+
)
|
|
777
|
+
assert rename_map == {"dest_file.py": "source_file.py"}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def test_added_lines_for_renamed_file_returns_empty_for_pure_rename(
|
|
781
|
+
temporary_git_repository: Path,
|
|
782
|
+
) -> None:
|
|
783
|
+
"""Blob comparison of a pure rename yields zero added lines."""
|
|
784
|
+
write_file(
|
|
785
|
+
temporary_git_repository / "old.py",
|
|
786
|
+
"a = 1\nb = 2\n",
|
|
787
|
+
)
|
|
788
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
789
|
+
run_git_in_repository(temporary_git_repository, "mv", "old.py", "new.py")
|
|
790
|
+
commit_all_files(temporary_git_repository, "rename")
|
|
791
|
+
|
|
792
|
+
merge_base = gate_module.resolve_merge_base(temporary_git_repository, "HEAD~1")
|
|
793
|
+
added = gate_module.added_lines_for_renamed_file(
|
|
794
|
+
temporary_git_repository.resolve(), merge_base, "old.py", "new.py"
|
|
795
|
+
)
|
|
796
|
+
assert added == set()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def test_added_lines_for_renamed_file_returns_empty_when_git_diff_fails(
|
|
800
|
+
temporary_git_repository: Path,
|
|
801
|
+
capsys: pytest.CaptureFixture[str],
|
|
802
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
803
|
+
) -> None:
|
|
804
|
+
"""Regression: transient git diff failure must not treat the file as all-new lines."""
|
|
805
|
+
write_file(
|
|
806
|
+
temporary_git_repository / "old.py",
|
|
807
|
+
"a = 1\nb = 2\n",
|
|
808
|
+
)
|
|
809
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
810
|
+
run_git_in_repository(temporary_git_repository, "mv", "old.py", "new.py")
|
|
811
|
+
commit_all_files(temporary_git_repository, "rename")
|
|
812
|
+
merge_base = gate_module.resolve_merge_base(temporary_git_repository, "HEAD~1")
|
|
813
|
+
|
|
814
|
+
failing = subprocess.CompletedProcess(
|
|
815
|
+
args=["git", "diff"],
|
|
816
|
+
returncode=1,
|
|
817
|
+
stdout="",
|
|
818
|
+
stderr="simulated git failure\n",
|
|
819
|
+
)
|
|
820
|
+
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: failing)
|
|
821
|
+
|
|
822
|
+
added = gate_module.added_lines_for_renamed_file(
|
|
823
|
+
temporary_git_repository.resolve(), merge_base, "old.py", "new.py"
|
|
824
|
+
)
|
|
825
|
+
assert added == set()
|
|
826
|
+
err = capsys.readouterr().err
|
|
827
|
+
assert "simulated git failure" in err.lower()
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def test_whole_file_line_set_logs_decode_failure_to_stderr(
|
|
831
|
+
tmp_path: Path,
|
|
832
|
+
capsys: pytest.CaptureFixture[str],
|
|
833
|
+
) -> None:
|
|
834
|
+
"""Regression: silent UnicodeDecodeError swallow loses scope.
|
|
835
|
+
|
|
836
|
+
A non-UTF-8 newly-added file would return an empty set with no signal
|
|
837
|
+
to the operator. The function must emit a stderr line naming the file
|
|
838
|
+
and the decode error so the operator knows scope was lost.
|
|
839
|
+
"""
|
|
840
|
+
non_utf8_path = tmp_path / "non_utf8.py"
|
|
841
|
+
non_utf8_path.write_bytes(b"name = '\xff\xfe invalid utf8 bytes'\n")
|
|
842
|
+
|
|
843
|
+
line_numbers = gate_module.whole_file_line_set(non_utf8_path)
|
|
844
|
+
|
|
845
|
+
assert line_numbers == set()
|
|
846
|
+
captured = capsys.readouterr()
|
|
847
|
+
assert "non_utf8.py" in captured.err
|
|
848
|
+
assert "decode" in captured.err.lower() or "utf" in captured.err.lower()
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def test_check_wrapper_plumb_through_skips_class_methods_calling_module_delegate() -> None:
|
|
852
|
+
"""Regression: class methods must not be wrongly flagged as wrappers.
|
|
853
|
+
|
|
854
|
+
Wrapper detection walked every FunctionDef including methods inside a
|
|
855
|
+
ClassDef body, but the signature index only contained module-level
|
|
856
|
+
functions. A class method calling a module-level delegate with optional
|
|
857
|
+
kwargs received an empty wrapper_kwargs set and was flagged as dropping
|
|
858
|
+
the delegate's optional kwargs even though the class method's signature
|
|
859
|
+
is unrelated to wrapper-style forwarding. Class methods must be ignored
|
|
860
|
+
by wrapper-candidate enumeration.
|
|
861
|
+
"""
|
|
862
|
+
source = (
|
|
863
|
+
"def fetch(target, *, retries=3):\n"
|
|
864
|
+
" return target\n"
|
|
865
|
+
"\n"
|
|
866
|
+
"class MyService:\n"
|
|
867
|
+
" def public_method(self, target):\n"
|
|
868
|
+
" return fetch(target)\n"
|
|
869
|
+
)
|
|
870
|
+
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
871
|
+
assert issues == [], (
|
|
872
|
+
f"class methods must not be treated as module-level wrappers; got {issues!r}"
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def test_renamed_file_source_map_since_uses_null_byte_separator(
|
|
877
|
+
temporary_git_repository: Path,
|
|
878
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
879
|
+
) -> None:
|
|
880
|
+
"""Regression: rename parsing must invoke git diff with -z.
|
|
881
|
+
|
|
882
|
+
`git diff --name-status -M` without -z separates columns with tab and
|
|
883
|
+
rows with newline. Filenames containing a literal tab or newline byte
|
|
884
|
+
break column detection and silently misclassify the rename. The -z
|
|
885
|
+
flag asks git for null-terminated, unquoted output so embedded tabs
|
|
886
|
+
and newlines round-trip correctly. This test asserts the function
|
|
887
|
+
invokes git with -z and parses the null-terminated stream emitted by
|
|
888
|
+
that flag.
|
|
889
|
+
"""
|
|
890
|
+
captured_arguments: dict[str, list[str]] = {}
|
|
891
|
+
|
|
892
|
+
null_terminated_stream = (
|
|
893
|
+
"R100\x00source_with\ttab.py\x00destination_with\ttab.py\x00"
|
|
894
|
+
).encode("utf-8")
|
|
895
|
+
|
|
896
|
+
class _FakeCompletedProcess:
|
|
897
|
+
returncode = 0
|
|
898
|
+
stdout = null_terminated_stream
|
|
899
|
+
stderr = b""
|
|
900
|
+
|
|
901
|
+
def _fake_subprocess_run(all_command, **_keyword_arguments):
|
|
902
|
+
captured_arguments["all_command"] = list(all_command)
|
|
903
|
+
return _FakeCompletedProcess()
|
|
904
|
+
|
|
905
|
+
monkeypatch.setattr(gate_module.subprocess, "run", _fake_subprocess_run)
|
|
906
|
+
|
|
907
|
+
rename_map = gate_module.renamed_file_source_map_since(
|
|
908
|
+
temporary_git_repository.resolve(), "deadbeef"
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
assert "-z" in captured_arguments["all_command"], (
|
|
912
|
+
"git diff --name-status -M must include -z so embedded tabs/newlines "
|
|
913
|
+
"in filenames are not misparsed as column or row separators"
|
|
914
|
+
)
|
|
915
|
+
assert rename_map == {
|
|
916
|
+
"destination_with\ttab.py": "source_with\ttab.py",
|
|
917
|
+
}
|