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,102 @@
1
+ """Tests for code_rules_gate_constants.py extracted constant set."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = (
10
+ Path(__file__).parent.parent / "config" / "code_rules_gate_constants.py"
11
+ )
12
+ specification = importlib.util.spec_from_file_location(
13
+ "config.code_rules_gate_constants", module_path
14
+ )
15
+ assert specification is not None
16
+ assert specification.loader is not None
17
+ module = importlib.util.module_from_spec(specification)
18
+ specification.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ constants_module = _load_constants_module()
23
+
24
+
25
+ def test_max_violations_per_check_is_typed_integer() -> None:
26
+ assert isinstance(constants_module.MAX_VIOLATIONS_PER_CHECK, int)
27
+ assert constants_module.MAX_VIOLATIONS_PER_CHECK == 3
28
+
29
+
30
+ def test_expected_tuple_pair_length_is_typed_integer() -> None:
31
+ assert isinstance(constants_module.EXPECTED_TUPLE_PAIR_LENGTH, int)
32
+ assert constants_module.EXPECTED_TUPLE_PAIR_LENGTH == 2
33
+
34
+
35
+ def test_all_code_file_extensions_is_frozenset() -> None:
36
+ assert isinstance(constants_module.ALL_CODE_FILE_EXTENSIONS, frozenset)
37
+ assert constants_module.ALL_CODE_FILE_EXTENSIONS == frozenset(
38
+ {".py", ".js", ".ts", ".tsx", ".jsx"}
39
+ )
40
+
41
+
42
+ def test_all_literal_keyword_exemptions_is_frozenset() -> None:
43
+ assert isinstance(constants_module.ALL_LITERAL_KEYWORD_EXEMPTIONS, frozenset)
44
+ assert constants_module.ALL_LITERAL_KEYWORD_EXEMPTIONS == frozenset(
45
+ {"true", "false", "none", "null"}
46
+ )
47
+
48
+
49
+ def test_config_path_segment() -> None:
50
+ assert constants_module.CONFIG_PATH_SEGMENT == "/config/"
51
+
52
+
53
+ def test_tests_path_segment() -> None:
54
+ assert constants_module.TESTS_PATH_SEGMENT == "/tests/"
55
+
56
+
57
+ def test_test_filename_suffixes_present() -> None:
58
+ assert "_test.py" in constants_module.ALL_TEST_FILENAME_SUFFIXES
59
+
60
+
61
+ def test_test_filename_glob_suffixes_present() -> None:
62
+ assert ".test." in constants_module.ALL_TEST_FILENAME_GLOB_SUFFIXES
63
+ assert ".spec." in constants_module.ALL_TEST_FILENAME_GLOB_SUFFIXES
64
+
65
+
66
+ def test_test_conftest_filename() -> None:
67
+ assert constants_module.TEST_CONFTEST_FILENAME == "conftest.py"
68
+
69
+
70
+ def test_test_filename_prefix() -> None:
71
+ assert constants_module.TEST_FILENAME_PREFIX == "test_"
72
+
73
+
74
+ def test_minimum_column_name_length_after_first_char() -> None:
75
+ assert constants_module.MINIMUM_COLUMN_NAME_LENGTH_AFTER_FIRST_CHAR == 2
76
+
77
+
78
+ def test_git_name_status_added_prefix() -> None:
79
+ assert constants_module.GIT_NAME_STATUS_ADDED_PREFIX == "A"
80
+
81
+
82
+ def test_git_name_status_renamed_prefix() -> None:
83
+ assert constants_module.GIT_NAME_STATUS_RENAMED_PREFIX == "R"
84
+
85
+
86
+ def test_expected_rename_column_count() -> None:
87
+ assert constants_module.EXPECTED_RENAME_COLUMN_COUNT == 3
88
+
89
+
90
+ def test_column_key_pattern_template_renders_with_minimum_length() -> None:
91
+ rendered_pattern = constants_module.COLUMN_KEY_PATTERN_TEMPLATE.format(
92
+ minimum_length=constants_module.MINIMUM_COLUMN_NAME_LENGTH_AFTER_FIRST_CHAR
93
+ )
94
+ assert rendered_pattern == r"^[a-z][a-z0-9_]{2,}$"
95
+
96
+
97
+ def test_git_diff_name_only_null_terminated_command_prefix_includes_dash_z() -> None:
98
+ command_prefix = (
99
+ constants_module.ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX
100
+ )
101
+ assert command_prefix == ("git", "diff", "--name-only", "-z")
102
+
@@ -0,0 +1,374 @@
1
+ """Tests for shared fix_hookspath.py extracted from skills/bugteam/scripts/.
2
+
3
+ Covers:
4
+ - removes a local-scope core.hooksPath override and re-runs preflight
5
+ - sets global core.hooksPath when missing
6
+ - idempotent: second invocation produces the same final state with no errors
7
+ - no-op when no override exists and global is already canonical
8
+ - exits non-zero with a clear message when canonical hooks dir is missing
9
+ - handles paths with spaces
10
+ """
11
+
12
+ import importlib.util
13
+ import os
14
+ import subprocess
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+
18
+ import pytest
19
+
20
+
21
+ def _load_fix_module() -> ModuleType:
22
+ module_path = Path(__file__).parent.parent / "fix_hookspath.py"
23
+ spec = importlib.util.spec_from_file_location("fix_hookspath", 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
+ fix_hookspath = _load_fix_module()
32
+
33
+
34
+ def _make_isolated_git_environment(home_directory: Path) -> dict[str, str]:
35
+ isolated_environment = os.environ.copy()
36
+ isolated_environment["HOME"] = str(home_directory)
37
+ isolated_environment["USERPROFILE"] = str(home_directory)
38
+ isolated_environment["XDG_CONFIG_HOME"] = str(home_directory / ".config")
39
+ isolated_environment["GIT_CONFIG_GLOBAL"] = str(home_directory / ".gitconfig")
40
+ isolated_environment["GIT_CONFIG_NOSYSTEM"] = "1"
41
+ return isolated_environment
42
+
43
+
44
+ def _initialize_repository(repository_path: Path, environment: dict[str, str]) -> None:
45
+ repository_path.mkdir(parents=True, exist_ok=True)
46
+ subprocess.run(
47
+ ["git", "init", "--quiet", str(repository_path)],
48
+ check=True,
49
+ env=environment,
50
+ )
51
+
52
+
53
+ def _set_local_hooks_path(
54
+ repository_path: Path,
55
+ hooks_path_value: str,
56
+ environment: dict[str, str],
57
+ ) -> None:
58
+ subprocess.run(
59
+ [
60
+ "git",
61
+ "-C",
62
+ str(repository_path),
63
+ "config",
64
+ "--local",
65
+ "core.hooksPath",
66
+ hooks_path_value,
67
+ ],
68
+ check=True,
69
+ env=environment,
70
+ )
71
+
72
+
73
+ def _set_global_hooks_path(hooks_path_value: str, environment: dict[str, str]) -> None:
74
+ subprocess.run(
75
+ ["git", "config", "--global", "core.hooksPath", hooks_path_value],
76
+ check=True,
77
+ env=environment,
78
+ )
79
+
80
+
81
+ def _read_local_hooks_path(repository_path: Path, environment: dict[str, str]) -> str:
82
+ completed_process = subprocess.run(
83
+ [
84
+ "git",
85
+ "-C",
86
+ str(repository_path),
87
+ "config",
88
+ "--local",
89
+ "--get",
90
+ "core.hooksPath",
91
+ ],
92
+ capture_output=True,
93
+ text=True,
94
+ check=False,
95
+ env=environment,
96
+ )
97
+ return completed_process.stdout.strip()
98
+
99
+
100
+ def _read_global_hooks_path(environment: dict[str, str]) -> str:
101
+ completed_process = subprocess.run(
102
+ ["git", "config", "--global", "--get", "core.hooksPath"],
103
+ capture_output=True,
104
+ text=True,
105
+ check=False,
106
+ env=environment,
107
+ )
108
+ return completed_process.stdout.strip()
109
+
110
+
111
+ def _create_canonical_hooks_directory(home_directory: Path) -> Path:
112
+ canonical_hooks_directory = home_directory / ".claude" / "hooks" / "git-hooks"
113
+ canonical_hooks_directory.mkdir(parents=True)
114
+ return canonical_hooks_directory
115
+
116
+
117
+ def test_should_remove_local_override_and_pass_preflight(tmp_path: Path) -> None:
118
+ home_directory = tmp_path / "home"
119
+ home_directory.mkdir()
120
+ environment = _make_isolated_git_environment(home_directory)
121
+ canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
122
+ _set_global_hooks_path(str(canonical_hooks_directory), environment)
123
+ repository_path = tmp_path / "synthetic-repo"
124
+ _initialize_repository(repository_path, environment)
125
+ stale_local_value = str(repository_path / ".git" / "hooks")
126
+ _set_local_hooks_path(repository_path, stale_local_value, environment)
127
+
128
+ exit_code = fix_hookspath.main(
129
+ ["--repo-root", str(repository_path)],
130
+ all_environment_overrides=environment,
131
+ )
132
+
133
+ assert exit_code == 0
134
+ assert _read_local_hooks_path(repository_path, environment) == ""
135
+
136
+
137
+ def test_should_set_global_hooks_path_when_missing(tmp_path: Path) -> None:
138
+ home_directory = tmp_path / "home"
139
+ home_directory.mkdir()
140
+ environment = _make_isolated_git_environment(home_directory)
141
+ canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
142
+ repository_path = tmp_path / "synthetic-repo"
143
+ _initialize_repository(repository_path, environment)
144
+ stale_local_value = str(repository_path / ".git" / "hooks")
145
+ _set_local_hooks_path(repository_path, stale_local_value, environment)
146
+
147
+ exit_code = fix_hookspath.main(
148
+ ["--repo-root", str(repository_path)],
149
+ all_environment_overrides=environment,
150
+ )
151
+
152
+ assert exit_code == 0
153
+ global_value_after_fix = _read_global_hooks_path(environment)
154
+ assert (
155
+ global_value_after_fix.replace("\\", "/")
156
+ .rstrip("/")
157
+ .endswith("hooks/git-hooks")
158
+ )
159
+
160
+
161
+ def test_should_be_idempotent(tmp_path: Path) -> None:
162
+ home_directory = tmp_path / "home"
163
+ home_directory.mkdir()
164
+ environment = _make_isolated_git_environment(home_directory)
165
+ canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
166
+ _set_global_hooks_path(str(canonical_hooks_directory), environment)
167
+ repository_path = tmp_path / "synthetic-repo"
168
+ _initialize_repository(repository_path, environment)
169
+ stale_local_value = str(repository_path / ".git" / "hooks")
170
+ _set_local_hooks_path(repository_path, stale_local_value, environment)
171
+
172
+ first_exit_code = fix_hookspath.main(
173
+ ["--repo-root", str(repository_path)],
174
+ all_environment_overrides=environment,
175
+ )
176
+ second_exit_code = fix_hookspath.main(
177
+ ["--repo-root", str(repository_path)],
178
+ all_environment_overrides=environment,
179
+ )
180
+
181
+ assert first_exit_code == 0
182
+ assert second_exit_code == 0
183
+ assert _read_local_hooks_path(repository_path, environment) == ""
184
+
185
+
186
+ def test_should_no_op_when_already_clean(tmp_path: Path) -> None:
187
+ home_directory = tmp_path / "home"
188
+ home_directory.mkdir()
189
+ environment = _make_isolated_git_environment(home_directory)
190
+ canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
191
+ _set_global_hooks_path(str(canonical_hooks_directory), environment)
192
+ repository_path = tmp_path / "synthetic-repo"
193
+ _initialize_repository(repository_path, environment)
194
+
195
+ exit_code = fix_hookspath.main(
196
+ ["--repo-root", str(repository_path)],
197
+ all_environment_overrides=environment,
198
+ )
199
+
200
+ assert exit_code == 0
201
+ assert _read_local_hooks_path(repository_path, environment) == ""
202
+ assert (
203
+ _read_global_hooks_path(environment)
204
+ .replace("\\", "/")
205
+ .rstrip("/")
206
+ .endswith("hooks/git-hooks")
207
+ )
208
+
209
+
210
+ def test_should_exit_nonzero_when_canonical_hooks_directory_missing(
211
+ tmp_path: Path,
212
+ capsys: pytest.CaptureFixture[str],
213
+ ) -> None:
214
+ home_directory = tmp_path / "home"
215
+ home_directory.mkdir()
216
+ environment = _make_isolated_git_environment(home_directory)
217
+ repository_path = tmp_path / "synthetic-repo"
218
+ _initialize_repository(repository_path, environment)
219
+ stale_local_value = str(repository_path / ".git" / "hooks")
220
+ _set_local_hooks_path(repository_path, stale_local_value, environment)
221
+
222
+ exit_code = fix_hookspath.main(
223
+ ["--repo-root", str(repository_path)],
224
+ all_environment_overrides=environment,
225
+ )
226
+
227
+ assert exit_code != 0
228
+ captured_streams = capsys.readouterr()
229
+ assert "hooks/git-hooks" in captured_streams.err.replace("\\", "/")
230
+
231
+
232
+ def test_constant_wrapper_functions_have_been_removed() -> None:
233
+ """The three wrappers returned an already-imported module-level constant
234
+ unchanged. They added a layer of indirection with no transformation,
235
+ validation, or test seam, so they were inlined at every call site
236
+ and removed.
237
+ """
238
+ assert not hasattr(fix_hookspath, "_expected_hooks_path_suffix")
239
+ assert not hasattr(fix_hookspath, "_canonical_hooks_directory_components")
240
+ assert not hasattr(fix_hookspath, "_home_env_var_names")
241
+
242
+
243
+ def test_is_canonical_hooks_path_still_recognizes_canonical_suffix() -> None:
244
+ canonical_value_with_suffix = "/home/example/.claude/hooks/git-hooks"
245
+ assert fix_hookspath.is_canonical_hooks_path(canonical_value_with_suffix)
246
+
247
+
248
+ def test_resolve_canonical_hooks_directory_uses_home_env_overrides(
249
+ tmp_path: Path,
250
+ ) -> None:
251
+ fake_home = tmp_path / "fake_home"
252
+ fake_home.mkdir()
253
+ overrides = {"HOME": str(fake_home), "USERPROFILE": str(fake_home)}
254
+ resolved = fix_hookspath.resolve_canonical_hooks_directory(overrides)
255
+ assert resolved == fake_home / ".claude" / "hooks" / "git-hooks"
256
+
257
+
258
+ def test_list_local_core_hooks_path_values_surfaces_git_stderr(
259
+ tmp_path: Path,
260
+ capsys: pytest.CaptureFixture[str],
261
+ monkeypatch: pytest.MonkeyPatch,
262
+ ) -> None:
263
+ """When git -C ... config --get-all exits non-zero with stderr, the helper
264
+ must print a diagnostic to sys.stderr so the failure is distinguishable from
265
+ "no local override exists".
266
+ """
267
+ failing_completed_process = subprocess.CompletedProcess(
268
+ args=["git"],
269
+ returncode=128,
270
+ stdout="",
271
+ stderr="fatal: not a git repository (or any parent up to mount point /)",
272
+ )
273
+ monkeypatch.setattr(
274
+ fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: failing_completed_process
275
+ )
276
+
277
+ returned_values = fix_hookspath.list_local_core_hooks_path_values(
278
+ tmp_path / "any-repo", None
279
+ )
280
+
281
+ assert returned_values == []
282
+ captured_streams = capsys.readouterr()
283
+ assert "fix_hookspath" in captured_streams.err
284
+ assert "core.hooksPath" in captured_streams.err
285
+ assert "not a git repository" in captured_streams.err
286
+
287
+
288
+ def test_list_local_core_hooks_path_values_quiet_when_stderr_empty(
289
+ tmp_path: Path,
290
+ capsys: pytest.CaptureFixture[str],
291
+ monkeypatch: pytest.MonkeyPatch,
292
+ ) -> None:
293
+ """`git config --get-all` exits 1 with empty stderr when the key is simply
294
+ unset. That is the dominant happy path and must NOT emit a diagnostic."""
295
+ unset_completed_process = subprocess.CompletedProcess(
296
+ args=["git"], returncode=1, stdout="", stderr=""
297
+ )
298
+ monkeypatch.setattr(
299
+ fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: unset_completed_process
300
+ )
301
+
302
+ returned_values = fix_hookspath.list_local_core_hooks_path_values(
303
+ tmp_path / "any-repo", None
304
+ )
305
+
306
+ assert returned_values == []
307
+ captured_streams = capsys.readouterr()
308
+ assert captured_streams.err == ""
309
+
310
+
311
+ def test_read_global_core_hooks_path_surfaces_git_stderr(
312
+ capsys: pytest.CaptureFixture[str],
313
+ monkeypatch: pytest.MonkeyPatch,
314
+ ) -> None:
315
+ """When the global git-config read exits non-zero with stderr, the helper
316
+ must print a diagnostic so callers can distinguish "global unset" from
317
+ "git broken"."""
318
+ failing_completed_process = subprocess.CompletedProcess(
319
+ args=["git"],
320
+ returncode=128,
321
+ stdout="",
322
+ stderr="fatal: bad config line 1 in file /home/example/.gitconfig",
323
+ )
324
+ monkeypatch.setattr(
325
+ fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: failing_completed_process
326
+ )
327
+
328
+ returned_value = fix_hookspath.read_global_core_hooks_path(None)
329
+
330
+ assert returned_value == ""
331
+ captured_streams = capsys.readouterr()
332
+ assert "fix_hookspath" in captured_streams.err
333
+ assert "core.hooksPath" in captured_streams.err
334
+ assert "bad config" in captured_streams.err
335
+
336
+
337
+ def test_read_global_core_hooks_path_quiet_when_stderr_empty(
338
+ capsys: pytest.CaptureFixture[str],
339
+ monkeypatch: pytest.MonkeyPatch,
340
+ ) -> None:
341
+ """`git config --global --get` exits 1 with empty stderr when the key is
342
+ simply unset. That is the dominant happy path and must NOT emit a diagnostic."""
343
+ unset_completed_process = subprocess.CompletedProcess(
344
+ args=["git"], returncode=1, stdout="", stderr=""
345
+ )
346
+ monkeypatch.setattr(
347
+ fix_hookspath.subprocess, "run", lambda *_args, **_kwargs: unset_completed_process
348
+ )
349
+
350
+ returned_value = fix_hookspath.read_global_core_hooks_path(None)
351
+
352
+ assert returned_value == ""
353
+ captured_streams = capsys.readouterr()
354
+ assert captured_streams.err == ""
355
+
356
+
357
+ def test_should_handle_paths_with_spaces(tmp_path: Path) -> None:
358
+ home_directory = tmp_path / "home with space"
359
+ home_directory.mkdir()
360
+ environment = _make_isolated_git_environment(home_directory)
361
+ canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
362
+ _set_global_hooks_path(str(canonical_hooks_directory), environment)
363
+ repository_path = tmp_path / "repo with space"
364
+ _initialize_repository(repository_path, environment)
365
+ stale_local_value = str(repository_path / ".git" / "hooks")
366
+ _set_local_hooks_path(repository_path, stale_local_value, environment)
367
+
368
+ exit_code = fix_hookspath.main(
369
+ ["--repo-root", str(repository_path)],
370
+ all_environment_overrides=environment,
371
+ )
372
+
373
+ assert exit_code == 0
374
+ assert _read_local_hooks_path(repository_path, environment) == ""
@@ -0,0 +1,47 @@
1
+ """Tests for fix_hookspath_constants.
2
+
3
+ Confirms HOOKS_PATH_SUFFIX is the full 3-component canonical hooks path so
4
+ validators cannot accept arbitrary directories that merely end in
5
+ ``hooks/git-hooks``.
6
+ """
7
+
8
+ import importlib.util
9
+ from pathlib import Path
10
+ from types import ModuleType
11
+
12
+
13
+ def _load_constants_module() -> ModuleType:
14
+ module_path = Path(__file__).parent.parent / "config" / "fix_hookspath_constants.py"
15
+ specification = importlib.util.spec_from_file_location(
16
+ "config.fix_hookspath_constants", module_path
17
+ )
18
+ assert specification is not None
19
+ assert specification.loader is not None
20
+ module = importlib.util.module_from_spec(specification)
21
+ specification.loader.exec_module(module)
22
+ return module
23
+
24
+
25
+ constants_module = _load_constants_module()
26
+
27
+
28
+ def test_hooks_path_suffix_uses_full_three_component_canonical_suffix() -> None:
29
+ assert constants_module.HOOKS_PATH_SUFFIX == "/".join(
30
+ constants_module.ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS
31
+ )
32
+ assert constants_module.HOOKS_PATH_SUFFIX == ".claude/hooks/git-hooks"
33
+
34
+
35
+ def test_canonical_hooks_directory_components_remain_three_component_tuple() -> None:
36
+ assert constants_module.ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS == (
37
+ ".claude",
38
+ "hooks",
39
+ "git-hooks",
40
+ )
41
+
42
+
43
+ def test_hooks_path_verification_suffix_is_two_component_for_backward_compat() -> None:
44
+ assert constants_module.HOOKS_PATH_VERIFICATION_SUFFIX == "hooks/git-hooks"
45
+ assert constants_module.HOOKS_PATH_VERIFICATION_SUFFIX == "/".join(
46
+ constants_module.ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS[-2:]
47
+ )