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.
Files changed (101) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +449 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/hooks/blocking/code_rules_enforcer.py +269 -23
  37. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
  38. package/hooks/config/test_unused_module_import_constants.py +48 -0
  39. package/hooks/config/unused_module_import_constants.py +41 -0
  40. package/package.json +2 -1
  41. package/skills/bg-agent/SKILL.md +69 -0
  42. package/skills/bugteam/CONSTRAINTS.md +10 -19
  43. package/skills/bugteam/PROMPTS.md +3 -3
  44. package/skills/bugteam/SKILL.md +103 -202
  45. package/skills/bugteam/SKILL_EVALS.md +75 -114
  46. package/skills/bugteam/reference/README.md +2 -4
  47. package/skills/bugteam/reference/design-rationale.md +3 -8
  48. package/skills/bugteam/reference/team-setup.md +11 -19
  49. package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
  50. package/skills/bugteam/scripts/config/__init__.py +0 -0
  51. package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
  52. package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
  53. package/skills/bugteam/sources.md +1 -25
  54. package/skills/bugteam/test_skill_additions.py +4 -13
  55. package/skills/fresh-branch/SKILL.md +71 -0
  56. package/skills/gotcha/SKILL.md +73 -0
  57. package/skills/monitor-open-prs/SKILL.md +4 -37
  58. package/skills/monitor-open-prs/test_skill_contract.py +0 -5
  59. package/skills/pr-converge/SKILL.md +60 -1298
  60. package/skills/pr-converge/reference/convergence-gates.md +118 -0
  61. package/skills/pr-converge/reference/examples.md +76 -0
  62. package/skills/pr-converge/reference/fix-protocol.md +54 -0
  63. package/skills/pr-converge/reference/ground-rules.md +13 -0
  64. package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
  65. package/skills/pr-converge/reference/per-tick.md +201 -0
  66. package/skills/pr-converge/reference/state-schema.md +19 -0
  67. package/skills/pr-converge/reference/stop-conditions.md +26 -0
  68. package/skills/pr-converge/scripts/README.md +36 -9
  69. package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
  70. package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
  71. package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
  72. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
  73. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
  74. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
  75. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
  76. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
  77. package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
  78. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
  79. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
  80. package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
  81. package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
  82. package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
  83. package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
  84. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
  85. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
  86. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
  87. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
  88. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
  89. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
  90. package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
  91. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
  92. package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
  93. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
  94. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
  95. package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
  96. package/skills/bugteam/test_team_lifecycle.py +0 -103
  97. package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
  98. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
  99. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
  100. package/skills/pr-converge/test_team_lifecycle.py +0 -56
  101. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
@@ -0,0 +1,670 @@
1
+ """Tests for preflight git hooks path verification.
2
+
3
+ Covers:
4
+ - core.hooksPath unset: exits non-zero with correction message
5
+ - core.hooksPath pointing to the correct claude hooks dir: exits zero
6
+ - core.hooksPath pointing elsewhere (husky override): exits non-zero
7
+ - core.hooksPath with trailing slash: must still pass after normalization
8
+ """
9
+
10
+ import importlib.util
11
+ import inspect
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+ from unittest.mock import ANY, MagicMock, patch
18
+
19
+ import pytest
20
+
21
+
22
+ def _load_preflight_module() -> ModuleType:
23
+ module_path = Path(__file__).parent.parent / "preflight.py"
24
+ spec = importlib.util.spec_from_file_location("preflight", module_path)
25
+ assert spec is not None
26
+ assert spec.loader is not None
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ preflight = _load_preflight_module()
33
+
34
+ from config.preflight_constants import ( # noqa: E402
35
+ PYTEST_INI_FILENAME,
36
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
37
+ )
38
+
39
+
40
+ def _make_completed_process(
41
+ stdout: str, returncode: int
42
+ ) -> subprocess.CompletedProcess:
43
+ process = MagicMock(spec=subprocess.CompletedProcess)
44
+ process.stdout = stdout
45
+ process.returncode = returncode
46
+ return process
47
+
48
+
49
+ def test_should_exit_nonzero_when_core_hooks_path_unset(
50
+ capsys: pytest.CaptureFixture[str],
51
+ ) -> None:
52
+ with patch("subprocess.run") as mock_run:
53
+ mock_run.return_value = _make_completed_process("", returncode=1)
54
+ exit_code = preflight.verify_git_hooks_path()
55
+ assert exit_code != 0
56
+ captured = capsys.readouterr()
57
+ assert "core.hooksPath" in captured.err
58
+ assert "npx claude-dev-env" in captured.err or "git config" in captured.err
59
+
60
+
61
+ def test_should_exit_zero_when_core_hooks_path_points_to_claude_hooks(
62
+ tmp_path: Path,
63
+ ) -> None:
64
+ claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
65
+ claude_hooks_path.mkdir(parents=True)
66
+ with patch("subprocess.run") as mock_run:
67
+ mock_run.return_value = _make_completed_process(
68
+ str(claude_hooks_path) + "\n", returncode=0
69
+ )
70
+ exit_code = preflight.verify_git_hooks_path()
71
+ assert exit_code == 0
72
+
73
+
74
+ def test_should_exit_nonzero_when_core_hooks_path_points_elsewhere(
75
+ capsys: pytest.CaptureFixture[str],
76
+ ) -> None:
77
+ with patch("subprocess.run") as mock_run:
78
+ mock_run.return_value = _make_completed_process(
79
+ "/some/other/path/.husky\n", returncode=0
80
+ )
81
+ exit_code = preflight.verify_git_hooks_path()
82
+ assert exit_code != 0
83
+ captured = capsys.readouterr()
84
+ assert "core.hooksPath" in captured.err
85
+
86
+
87
+ def test_should_include_correction_commands_in_error_message(
88
+ capsys: pytest.CaptureFixture[str],
89
+ ) -> None:
90
+ with patch("subprocess.run") as mock_run:
91
+ mock_run.return_value = _make_completed_process("", returncode=1)
92
+ preflight.verify_git_hooks_path()
93
+ captured = capsys.readouterr()
94
+ assert (
95
+ "npx claude-dev-env" in captured.err
96
+ or "git config --global core.hooksPath" in captured.err
97
+ )
98
+
99
+
100
+ def test_main_should_exit_nonzero_when_hooks_path_unset() -> None:
101
+ with patch("subprocess.run") as mock_run:
102
+ mock_run.return_value = _make_completed_process("", returncode=1)
103
+ exit_code = preflight.main(["--no-pytest"])
104
+ assert exit_code != 0
105
+
106
+
107
+ def test_main_should_continue_when_hooks_path_valid(tmp_path: Path) -> None:
108
+ claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
109
+ claude_hooks_path.mkdir(parents=True)
110
+ with patch("subprocess.run") as mock_run:
111
+ mock_run.return_value = _make_completed_process(
112
+ str(claude_hooks_path) + "\n", returncode=0
113
+ )
114
+ exit_code = preflight.main(["--no-pytest"])
115
+ assert exit_code == 0
116
+
117
+
118
+ def test_should_accept_hooks_path_with_trailing_slash() -> None:
119
+ with patch("subprocess.run") as mock_run:
120
+ mock_run.return_value = _make_completed_process(
121
+ "/home/user/.claude/hooks/git-hooks/\n", returncode=0
122
+ )
123
+ exit_code = preflight.verify_git_hooks_path()
124
+ assert exit_code == 0, (
125
+ "hooksPath with trailing slash must pass verification after normalization"
126
+ )
127
+
128
+
129
+ def test_should_exit_zero_when_hooks_path_set_at_repo_scope(tmp_path: Path) -> None:
130
+ claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
131
+ claude_hooks_path.mkdir(parents=True)
132
+ repo_root = tmp_path / "my-repo"
133
+ repo_root.mkdir()
134
+ with patch("subprocess.run") as mock_run:
135
+ mock_run.return_value = _make_completed_process(
136
+ str(claude_hooks_path) + "\n", returncode=0
137
+ )
138
+ exit_code = preflight.verify_git_hooks_path(repo_root)
139
+ assert exit_code == 0, (
140
+ "verify_git_hooks_path must accept a valid path returned by effective "
141
+ "config query (not restricted to --global scope)"
142
+ )
143
+ called_command = mock_run.call_args[0][0]
144
+ assert "--global" not in called_command, (
145
+ "verify_git_hooks_path must query effective config, not --global only"
146
+ )
147
+ assert "-C" in called_command, (
148
+ "verify_git_hooks_path must use git -C <repo_root> for repo-effective config"
149
+ )
150
+ dash_c_index = called_command.index("-C")
151
+ assert called_command[dash_c_index + 1] == str(repo_root), (
152
+ "git -C must receive the resolved repository root path"
153
+ )
154
+
155
+
156
+ def test_should_accept_hooks_path_with_backslash_and_trailing_slash() -> None:
157
+ with patch("subprocess.run") as mock_run:
158
+ mock_run.return_value = _make_completed_process(
159
+ "C:\\Users\\user\\.claude\\hooks\\git-hooks\\\n", returncode=0
160
+ )
161
+ exit_code = preflight.verify_git_hooks_path()
162
+ assert exit_code == 0, (
163
+ "Windows hooksPath with trailing backslash must pass after normalization"
164
+ )
165
+
166
+
167
+ def test_should_exit_nonzero_when_git_executable_not_found(
168
+ capsys: pytest.CaptureFixture[str],
169
+ ) -> None:
170
+ """Preflight must not crash with a traceback when git is missing from PATH."""
171
+ with patch("subprocess.run", side_effect=FileNotFoundError()):
172
+ exit_code = preflight.verify_git_hooks_path()
173
+ assert exit_code != 0, (
174
+ "FileNotFoundError from subprocess.run must produce a non-zero exit, "
175
+ "not a propagated traceback"
176
+ )
177
+ captured = capsys.readouterr()
178
+ assert "git" in captured.err.lower(), (
179
+ "Error message must mention git so the user knows what is missing"
180
+ )
181
+ assert (
182
+ "npx claude-dev-env" in captured.err
183
+ or "git config --global core.hooksPath" in captured.err
184
+ ), "Error message must include the enforcement-absent remediation hints"
185
+
186
+
187
+ def test_should_exit_nonzero_when_subprocess_run_raises_os_error(
188
+ capsys: pytest.CaptureFixture[str],
189
+ ) -> None:
190
+ """Preflight must surface a clean error for other OS-level git launch failures."""
191
+ with patch("subprocess.run", side_effect=OSError("permission denied")):
192
+ exit_code = preflight.verify_git_hooks_path()
193
+ assert exit_code != 0, (
194
+ "OSError from subprocess.run must produce a non-zero exit, "
195
+ "not a propagated traceback"
196
+ )
197
+ captured = capsys.readouterr()
198
+ assert "preflight" in captured.err, (
199
+ "Error message must be prefixed with the preflight tool name for context"
200
+ )
201
+ assert "permission denied" in captured.err, (
202
+ "Error message must include the underlying OSError detail for diagnosis"
203
+ )
204
+
205
+
206
+ def test_preflight_uses_shared_hooks_path_suffix_constant() -> None:
207
+ """Preflight's expected suffix must come from config.fix_hookspath_constants
208
+ so the canonical hooks directory is defined in exactly one place."""
209
+ scripts_directory = str(Path(__file__).parent.parent.resolve())
210
+ if scripts_directory not in sys.path:
211
+ sys.path.insert(0, scripts_directory)
212
+ constants_module_path = (
213
+ Path(__file__).parent.parent / "config" / "fix_hookspath_constants.py"
214
+ )
215
+ constants_specification = importlib.util.spec_from_file_location(
216
+ "config.fix_hookspath_constants",
217
+ constants_module_path,
218
+ )
219
+ assert constants_specification is not None
220
+ assert constants_specification.loader is not None
221
+ constants_module = importlib.util.module_from_spec(constants_specification)
222
+ constants_specification.loader.exec_module(constants_module)
223
+ expected_suffix = constants_module.HOOKS_PATH_VERIFICATION_SUFFIX
224
+
225
+ with patch("subprocess.run") as mock_run:
226
+ mock_run.return_value = _make_completed_process(
227
+ f"/some/where/{expected_suffix}\n", returncode=0
228
+ )
229
+ exit_code = preflight.verify_git_hooks_path()
230
+ assert exit_code == 0
231
+
232
+
233
+ def test_preflight_skip_uses_shared_env_var_constant(
234
+ capsys: pytest.CaptureFixture[str],
235
+ monkeypatch: pytest.MonkeyPatch,
236
+ ) -> None:
237
+ """The preflight skip env-var name must come from config/preflight_constants.py."""
238
+ scripts_directory = str(Path(__file__).parent.parent.resolve())
239
+ if scripts_directory not in sys.path:
240
+ sys.path.insert(0, scripts_directory)
241
+ constants_module_path = (
242
+ Path(__file__).parent.parent / "config" / "preflight_constants.py"
243
+ )
244
+ constants_specification = importlib.util.spec_from_file_location(
245
+ "config.preflight_constants",
246
+ constants_module_path,
247
+ )
248
+ assert constants_specification is not None
249
+ assert constants_specification.loader is not None
250
+ constants_module = importlib.util.module_from_spec(constants_specification)
251
+ constants_specification.loader.exec_module(constants_module)
252
+ skip_env_var_name = constants_module.BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME
253
+
254
+ monkeypatch.setenv(skip_env_var_name, "1")
255
+ exit_code = preflight.main(["--no-pytest"])
256
+ assert exit_code == 0
257
+ captured = capsys.readouterr()
258
+ assert skip_env_var_name in captured.err
259
+
260
+
261
+ def test_loop_variables_use_each_prefix_in_preflight_module() -> None:
262
+ find_root_source = inspect.getsource(preflight.find_repository_root)
263
+ assert "for each_candidate in" in find_root_source
264
+
265
+ discover_tests_source = inspect.getsource(preflight.has_discoverable_tests)
266
+ assert "ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND" in discover_tests_source
267
+
268
+
269
+ def test_preflight_uses_extracted_directory_marker_constants() -> None:
270
+ preflight_source = inspect.getsource(preflight)
271
+ assert "GIT_DIRECTORY_NAME" in preflight_source
272
+ assert "ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND" in preflight_source
273
+ find_root_source = inspect.getsource(preflight.find_repository_root)
274
+ assert "'.git'" not in find_root_source
275
+ assert '".git"' not in find_root_source
276
+ discover_tests_source = inspect.getsource(preflight.has_discoverable_tests)
277
+ assert "ALL_GIT_LS_FILES_TEST_DISCOVERY_SUBCOMMAND" in discover_tests_source
278
+
279
+
280
+ def test_preflight_stderr_uses_bugteam_preflight_prefix(
281
+ capsys: pytest.CaptureFixture[str],
282
+ ) -> None:
283
+ """Preflight's stderr prefix must remain ``bugteam_preflight:`` so the bugteam
284
+ SKILL.md auto-remediation pattern (`bugteam_preflight: core.hooksPath is`)
285
+ keeps matching when Phase 2 wires bugteam to import this shared script."""
286
+ with patch("subprocess.run") as mock_run:
287
+ mock_run.return_value = _make_completed_process(
288
+ "/some/other/path/.husky\n", returncode=0
289
+ )
290
+ preflight.verify_git_hooks_path()
291
+ captured = capsys.readouterr()
292
+ assert "bugteam_preflight: core.hooksPath is" in captured.err, (
293
+ "Stderr prefix must preserve the bugteam SKILL.md auto-remediation contract"
294
+ )
295
+
296
+
297
+ def test_preflight_does_not_import_unused_repository_root_marker_constant() -> None:
298
+ """The ``ALL_REPOSITORY_ROOT_MARKER_FILENAMES`` constant is not consumed by
299
+ preflight.py. Importing it is dead code per the unused-imports rule."""
300
+ preflight_source = inspect.getsource(preflight)
301
+ assert "ALL_REPOSITORY_ROOT_MARKER_FILENAMES" not in preflight_source, (
302
+ "Dead import must be removed; preflight.py uses individual marker "
303
+ "filename constants directly"
304
+ )
305
+
306
+
307
+ def test_pytest_no_tests_collected_helper_returns_named_constant() -> None:
308
+ """The pytest "no tests collected" exit code must be sourced from the
309
+ named constant in config/preflight_constants.py rather than the bare
310
+ literal 5 inside the function body (CODE_RULES magic-values rule)."""
311
+ assert preflight._pytest_exit_code_no_tests_collected() == (
312
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE
313
+ )
314
+ helper_source = inspect.getsource(preflight._pytest_exit_code_no_tests_collected)
315
+ assert "PYTEST_NO_TESTS_COLLECTED_EXIT_CODE" in helper_source, (
316
+ "Helper body must return the named constant, not the bare literal 5"
317
+ )
318
+
319
+
320
+ def test_preflight_bootstrap_moves_script_directory_to_front() -> None:
321
+ """Import bootstrap keeps exactly one script directory entry at the front."""
322
+ module_path = Path(__file__).parent.parent / "preflight.py"
323
+ script_directory_resolved = str(module_path.parent.resolve())
324
+ script_directory_absolute = str(module_path.parent.absolute())
325
+ original_sys_path = list(sys.path)
326
+ try:
327
+ sys.path.insert(0, script_directory_resolved)
328
+ sys.path.insert(0, script_directory_resolved)
329
+ sys.path.insert(0, str(module_path.parents[4]))
330
+ _load_preflight_module()
331
+ assert os.path.samefile(sys.path[0], script_directory_resolved)
332
+ equivalent_count = sum(
333
+ 1
334
+ for each_entry in sys.path
335
+ if os.path.exists(each_entry)
336
+ and os.path.samefile(each_entry, script_directory_resolved)
337
+ )
338
+ assert equivalent_count == 1
339
+ assert sys.path[0] == script_directory_absolute
340
+ finally:
341
+ sys.path[:] = original_sys_path
342
+
343
+
344
+ def test_main_uses_correct_changed_files_function_name() -> None:
345
+ """main() must call get_changed_files, not the undefined get_all_changed_files."""
346
+ main_source = inspect.getsource(preflight.main)
347
+ assert "get_all_changed_files(" not in main_source
348
+
349
+
350
+ def test_should_not_return_nonexistent_test_file(tmp_path: Path) -> None:
351
+ """A deleted test file path from git diff --name-only must not be returned.
352
+ Before the fix, _find_related_test_files returned paths without checking
353
+ whether the file exists on disk, which caused pytest to receive
354
+ nonexistent paths for deleted files.
355
+ """
356
+ repo_root = tmp_path
357
+ deleted_test_path = Path("test_deleted_module.py")
358
+ result = preflight._find_related_test_files(deleted_test_path, repo_root)
359
+ assert result == []
360
+
361
+
362
+ def test_should_not_return_test_files_for_non_python_file(tmp_path: Path) -> None:
363
+ """A non-.py file must return an empty list regardless of file existence."""
364
+ repo_root = tmp_path
365
+ non_python_path = Path("readme.txt")
366
+ (repo_root / non_python_path).touch()
367
+ result = preflight._find_related_test_files(non_python_path, repo_root)
368
+ assert result == []
369
+
370
+
371
+ def test_should_find_test_file_in_adjacent_tests_directory(tmp_path: Path) -> None:
372
+ """A source file with a matching test in the adjacent tests/ directory
373
+ must return that test file path."""
374
+ repo_root = tmp_path
375
+ source_path = Path("src/module.py")
376
+ (repo_root / source_path).parent.mkdir(parents=True)
377
+ (repo_root / source_path).touch()
378
+ adjacent_tests = repo_root / "src" / "tests"
379
+ adjacent_tests.mkdir(parents=True)
380
+ expected_test = adjacent_tests / "test_module.py"
381
+ expected_test.touch()
382
+ result = preflight._find_related_test_files(source_path, repo_root)
383
+ assert expected_test in result
384
+
385
+
386
+ def test_should_find_test_file_in_top_level_tests_directory(tmp_path: Path) -> None:
387
+ """A source file with a matching test in the top-level tests/ directory
388
+ must return that test file path."""
389
+ repo_root = tmp_path
390
+ source_path = Path("src/module.py")
391
+ (repo_root / source_path).parent.mkdir(parents=True)
392
+ (repo_root / source_path).touch()
393
+ top_tests = repo_root / "tests" / "src"
394
+ top_tests.mkdir(parents=True)
395
+ expected_test = top_tests / "test_module.py"
396
+ expected_test.touch()
397
+ result = preflight._find_related_test_files(source_path, repo_root)
398
+ assert expected_test in result
399
+
400
+
401
+ def test_main_should_warn_when_scope_changed_without_base_ref(
402
+ capsys: pytest.CaptureFixture[str],
403
+ ) -> None:
404
+ """--scope changed with no --base-ref must warn and fall back to full suite."""
405
+ with (
406
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
407
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
408
+ patch.object(preflight, "has_discoverable_tests", return_value=True),
409
+ patch.object(preflight, "run_pytest", return_value=0),
410
+ ):
411
+ exit_code = preflight.main(["--scope", "changed"])
412
+ assert exit_code == 0
413
+ captured = capsys.readouterr()
414
+ assert "requires --base-ref" in captured.err, (
415
+ "Missing warning when --scope changed is used without --base-ref"
416
+ )
417
+
418
+
419
+ def test_has_discoverable_tests_should_not_re_raise_on_git_failure(
420
+ capsys: pytest.CaptureFixture[str],
421
+ tmp_path: Path,
422
+ ) -> None:
423
+ """has_discoverable_tests must return None instead of re-raising on git failure."""
424
+ (tmp_path / ".git").mkdir()
425
+ with patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, ["git"])):
426
+ result = preflight.has_discoverable_tests(tmp_path)
427
+ captured = capsys.readouterr()
428
+ assert result is None, (
429
+ "Should return None instead of propagating the exception"
430
+ )
431
+ assert "bugteam_preflight:" in captured.err
432
+ assert "git ls-files failed" in captured.err
433
+
434
+
435
+ def test_main_should_not_double_print_when_git_ls_fails(
436
+ capsys: pytest.CaptureFixture[str],
437
+ ) -> None:
438
+ """When git ls-files fails, main() must print a distinct failure warning and
439
+ run the full pytest suite instead of silently skipping tests."""
440
+ mock_hooks_result = _make_completed_process(
441
+ "/home/user/.claude/hooks/git-hooks\n", returncode=0
442
+ )
443
+ with (
444
+ patch("subprocess.run") as mock_run,
445
+ patch.object(preflight, "run_pytest", return_value=0) as mock_pytest,
446
+ ):
447
+ mock_run.side_effect = [
448
+ mock_hooks_result,
449
+ subprocess.CalledProcessError(128, ["git", "ls-files"]),
450
+ ]
451
+ exit_code = preflight.main([])
452
+ captured = capsys.readouterr()
453
+ assert "bugteam_preflight: test discovery failed" in captured.err, (
454
+ "Must print a distinct warning when discovery fails, not the 'no tests found' message"
455
+ )
456
+ assert "bugteam_preflight: pytest configured but no tests found" not in captured.err, (
457
+ "Must not print the 'no tests found' skip message when discovery fails"
458
+ )
459
+ mock_pytest.assert_called_once_with(ANY, False)
460
+
461
+
462
+ def test_should_default_to_changed_scope_when_base_ref_provided() -> None:
463
+ """--base-ref without --scope must default to 'changed', not 'all'.
464
+
465
+ The help text says 'Defaults to changed when --base-ref is provided'.
466
+ Before the fix, the None -> PYTEST_SCOPE_ALL conversion ran before
467
+ checking --base-ref, so providing --base-ref without --scope still
468
+ ran the full suite without calling get_changed_files.
469
+ """
470
+ with (
471
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
472
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
473
+ patch.object(preflight, "has_discoverable_tests", return_value=True),
474
+ patch.object(preflight, "get_changed_files") as mock_get_changed,
475
+ patch.object(preflight, "discover_related_tests", return_value=[]),
476
+ patch.object(preflight, "run_pytest", return_value=0),
477
+ ):
478
+ exit_code = preflight.main(["--base-ref", "origin/main"])
479
+ assert exit_code == 0
480
+ mock_get_changed.assert_called_once_with(
481
+ ANY, "origin/main"
482
+ )
483
+
484
+
485
+ def test_should_default_to_all_scope_when_no_base_ref_no_scope(
486
+ capsys: pytest.CaptureFixture[str],
487
+ ) -> None:
488
+ """Omitting both --scope and --base-ref must default to 'all'."""
489
+ with (
490
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
491
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
492
+ patch.object(preflight, "has_discoverable_tests", return_value=True),
493
+ patch.object(preflight, "run_pytest", return_value=0),
494
+ ):
495
+ exit_code = preflight.main([])
496
+ assert exit_code == 0
497
+ captured = capsys.readouterr()
498
+ assert "running full suite" not in captured.err, (
499
+ "Default scope=all should run directly without changed-scope messages"
500
+ )
501
+
502
+
503
+ def test_explicit_scope_all_with_base_ref_should_not_call_get_changed_files(
504
+ capsys: pytest.CaptureFixture[str],
505
+ ) -> None:
506
+ """Explicit --scope all with --base-ref must not auto-convert to 'changed'.
507
+
508
+ Before the fix, ``argparse`` defaulted ``--scope`` to ``PYTEST_SCOPE_ALL``
509
+ (``"all"``), making it impossible to distinguish "user typed --scope all"
510
+ versus "user omitted --scope". The code then auto-converted
511
+ ``effective_scope == "all"`` to ``"changed"`` whenever ``--base-ref`` was
512
+ present, silently overriding an explicit ``--scope all``.
513
+
514
+ After the fix, ``--scope`` defaults to ``None`` and is resolved to ``"all"``
515
+ only after argparse, so the user's explicit ``--scope all`` stays ``"all"``
516
+ and the full suite runs regardless of ``--base-ref``.
517
+ """
518
+ with (
519
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
520
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
521
+ patch.object(preflight, "has_discoverable_tests", return_value=True),
522
+ patch.object(preflight, "get_changed_files") as mock_get_changed,
523
+ patch.object(preflight, "discover_related_tests", return_value=[]),
524
+ patch.object(preflight, "run_pytest", return_value=0),
525
+ ):
526
+ exit_code = preflight.main(["--scope", "all", "--base-ref", "origin/main"])
527
+ assert exit_code == 0
528
+ mock_get_changed.assert_not_called()
529
+
530
+
531
+ def test_preflight_bootstrap_matches_code_rules_sys_path_pattern() -> None:
532
+ """Bootstrap must clear duplicate script_directory entries, then guard insert."""
533
+ module_path = Path(__file__).parent.parent / "preflight.py"
534
+ source = module_path.read_text(encoding="utf-8")
535
+ assert "_entry_points_at_preflight_script_directory" in source, (
536
+ "Bootstrap must remove script_directory entries using path equivalence"
537
+ )
538
+ assert "for each_index in range(len(sys.path) - 1, -1, -1):" in source, (
539
+ "Bootstrap must walk sys.path to drop duplicate script directory entries"
540
+ )
541
+ assert "_preflight_scripts_path_entry not in sys.path:" in source, (
542
+ "Bootstrap insert must be guarded for code_rules_gate compliance"
543
+ )
544
+ assert "sys.path.insert(0, _preflight_scripts_path_entry)" in source, (
545
+ "Bootstrap must insert the absolute script directory at index 0"
546
+ )
547
+
548
+
549
+ def test_has_discoverable_tests_should_include_untracked_test_files(
550
+ tmp_path: Path,
551
+ ) -> None:
552
+ """has_discoverable_tests must include --others --exclude-standard
553
+ to discover untracked test files not yet in the git index."""
554
+ (tmp_path / ".git").mkdir()
555
+ with patch("subprocess.run") as mock_run:
556
+ mock_run.return_value = _make_completed_process("untracked_test.py\n", returncode=0)
557
+ preflight.has_discoverable_tests(tmp_path)
558
+ called_command = mock_run.call_args[0][0]
559
+ assert "--others" in called_command, (
560
+ "--others flag required to include untracked files in ls-files output"
561
+ )
562
+ assert "--exclude-standard" in called_command, (
563
+ "--exclude-standard flag required to respect .gitignore for untracked files"
564
+ )
565
+
566
+
567
+ def test_run_pytest_should_use_positional_separator_before_test_paths() -> None:
568
+ """run_pytest must pass '--' before test paths so pytest does not misinterpret
569
+ paths starting with '-' as command-line options."""
570
+ with patch("subprocess.run") as mock_run:
571
+ mock_run.return_value = _make_completed_process("", returncode=0)
572
+ preflight.run_pytest(
573
+ Path("/fake/repository"),
574
+ verbose=False,
575
+ all_test_paths=[Path("test_copilot_finding.py")],
576
+ )
577
+ called_command = mock_run.call_args[0][0]
578
+ separator_index = called_command.index("--")
579
+ assert called_command[separator_index + 1:] == ["test_copilot_finding.py"], (
580
+ "All test paths must follow the '--' positional separator"
581
+ )
582
+
583
+
584
+ # ---- Copilot finding 1: has_discoverable_tests in non-git directories ----
585
+
586
+
587
+ def test_has_discoverable_tests_returns_true_when_no_git_marker(
588
+ tmp_path: Path,
589
+ ) -> None:
590
+ """has_discoverable_tests must return True without running git when the root
591
+ has no .git marker (e.g., repo root found via pytest.ini)."""
592
+ (tmp_path / PYTEST_INI_FILENAME).touch()
593
+ result = preflight.has_discoverable_tests(tmp_path)
594
+ assert result is True
595
+
596
+
597
+ # ---- Copilot finding 2: base_ref command injection ----
598
+
599
+
600
+ def test_get_changed_files_returns_none_when_base_ref_starts_with_hyphen(
601
+ capsys: pytest.CaptureFixture[str],
602
+ ) -> None:
603
+ """get_changed_files must return None and print a warning when base_ref
604
+ starts with '-', preventing option injection into git diff."""
605
+ result = preflight.get_changed_files(Path("/fake"), "-oMalicious")
606
+ assert result is None
607
+ captured = capsys.readouterr()
608
+ assert "base_ref" in captured.err
609
+ assert "hyphen" in captured.err
610
+
611
+
612
+ # ---- Copilot finding 3: duplicate git failures when discovery_result is None ----
613
+
614
+
615
+ def test_main_skips_changed_scope_when_discovery_result_is_none(
616
+ capsys: pytest.CaptureFixture[str],
617
+ ) -> None:
618
+ """When has_discoverable_tests returns None (git unavailable), main must
619
+ not call get_changed_files even when --base-ref is provided."""
620
+ with (
621
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
622
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
623
+ patch.object(preflight, "has_discoverable_tests", return_value=None),
624
+ patch.object(preflight, "get_changed_files") as mock_get_changed,
625
+ patch.object(preflight, "run_pytest", return_value=0),
626
+ ):
627
+ exit_code = preflight.main(["--base-ref", "origin/main"])
628
+ assert exit_code == 0
629
+ mock_get_changed.assert_not_called()
630
+
631
+
632
+ # ---- Copilot finding 4: misleading no-related-tests message on git diff failure ----
633
+
634
+
635
+ def test_main_does_not_print_no_related_tests_when_get_changed_files_returns_none(
636
+ capsys: pytest.CaptureFixture[str],
637
+ ) -> None:
638
+ """When get_changed_files returns None (git diff failed), main must not
639
+ print the misleading 'no related tests found' message."""
640
+ with (
641
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
642
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
643
+ patch.object(preflight, "has_discoverable_tests", return_value=True),
644
+ patch.object(preflight, "get_changed_files", return_value=None),
645
+ patch.object(preflight, "discover_related_tests", return_value=[]),
646
+ patch.object(preflight, "run_pytest", return_value=0),
647
+ ):
648
+ exit_code = preflight.main(["--scope", "changed", "--base-ref", "origin/main"])
649
+ assert exit_code == 0
650
+ captured = capsys.readouterr()
651
+ assert "no related tests found" not in captured.err
652
+
653
+
654
+ def test_main_prints_no_related_tests_when_get_changed_files_returns_empty(
655
+ capsys: pytest.CaptureFixture[str],
656
+ ) -> None:
657
+ """When get_changed_files returns [] (no changed files, git succeeded),
658
+ main must print the 'no related tests found' message and run full suite."""
659
+ with (
660
+ patch.object(preflight, "verify_git_hooks_path", return_value=0),
661
+ patch.object(preflight, "has_pytest_configuration", return_value=True),
662
+ patch.object(preflight, "has_discoverable_tests", return_value=True),
663
+ patch.object(preflight, "get_changed_files", return_value=[]),
664
+ patch.object(preflight, "discover_related_tests", return_value=[]),
665
+ patch.object(preflight, "run_pytest", return_value=0),
666
+ ):
667
+ exit_code = preflight.main(["--scope", "changed", "--base-ref", "origin/main"])
668
+ assert exit_code == 0
669
+ captured = capsys.readouterr()
670
+ assert "no related tests found" in captured.err