claude-dev-env 1.35.0 → 1.36.1

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 (115) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/bin/install.mjs +28 -8
  3. package/bin/install.test.mjs +9 -1
  4. package/docs/CODE_RULES.md +3 -0
  5. package/docs/agents-md-alignment-plan.md +123 -0
  6. package/hooks/blocking/code_rules_enforcer.py +451 -39
  7. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  8. package/hooks/blocking/test_code_rules_enforcer.py +182 -0
  9. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  10. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  11. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
  12. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
  13. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  14. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
  15. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
  16. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  17. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  18. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  19. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  20. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  21. package/hooks/config/banned_identifiers_constants.py +24 -0
  22. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  23. package/hooks/config/hook_log_extractor_constants.py +1 -1
  24. package/hooks/config/pre_tool_use_stdin.py +48 -0
  25. package/hooks/config/setup_project_paths_constants.py +4 -0
  26. package/hooks/config/stuttering_check_config.py +14 -0
  27. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  28. package/hooks/config/sys_path_insert_constants.py +4 -0
  29. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  30. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  31. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  32. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  33. package/hooks/config/unused_module_import_constants.py +7 -0
  34. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  35. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  36. package/hooks/git-hooks/config.py +3 -3
  37. package/hooks/git-hooks/test_gate_utils.py +10 -10
  38. package/hooks/mypy.ini +2 -0
  39. package/package.json +1 -1
  40. package/rules/gh-paginate.md +125 -0
  41. package/skills/bugteam/CONSTRAINTS.md +12 -6
  42. package/skills/bugteam/SKILL.md +364 -154
  43. package/skills/bugteam/SKILL_EVALS.md +25 -23
  44. package/skills/bugteam/reference/README.md +2 -0
  45. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  46. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  47. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  48. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  49. package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
  50. package/skills/bugteam/test_skill_additions.py +13 -4
  51. package/skills/bugteam/test_team_lifecycle.py +103 -0
  52. package/skills/findbugs/SKILL.md +3 -3
  53. package/skills/fixbugs/SKILL.md +4 -4
  54. package/skills/monitor-open-prs/SKILL.md +32 -2
  55. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  56. package/skills/pr-converge/SKILL.md +1206 -131
  57. package/skills/pr-converge/scripts/README.md +145 -0
  58. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  59. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  60. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  61. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  62. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  63. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  64. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  65. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  66. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  67. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  68. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  69. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  70. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  71. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  72. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  73. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  74. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  75. package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
  76. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  77. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  78. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  79. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  80. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  81. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  82. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  83. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  84. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  85. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  86. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  87. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  88. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  89. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  90. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  91. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  92. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  93. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  94. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  95. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  96. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  97. package/skills/pr-converge/test_team_lifecycle.py +56 -0
  98. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  99. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  100. package/skills/qbug/SKILL.md +4 -4
  101. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  102. package/skills/resume-review/SKILL.md +261 -0
  103. package/skills/bugteam/scripts/README.md +0 -58
  104. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  105. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  106. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  107. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  108. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  109. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  110. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  111. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  112. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  113. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  114. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  115. /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
@@ -1,135 +0,0 @@
1
- """Revoke the permissions previously granted by grant_project_claude_permissions.
2
-
3
- Run from the same project root you previously granted. Removes the matching
4
- allow rules, the additionalDirectories entry, and the autoMode environment
5
- entry from ~/.claude/settings.json. Safe to run when no prior grant exists.
6
- After removals, prunes any newly empty lists and their parent permissions or
7
- autoMode sections so repeated grant/revoke cycles leave no dead structure.
8
- """
9
-
10
- import sys
11
- from pathlib import Path
12
-
13
- sys.path.insert(0, str(Path(__file__).resolve().parent))
14
-
15
- from _claude_permissions_common import ( # noqa: E402
16
- build_permission_rules,
17
- exit_with_error,
18
- get_current_project_path,
19
- load_settings,
20
- prune_empty_list_then_empty_section,
21
- save_settings,
22
- AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
23
- PERMISSION_ALLOW_TOOLS,
24
- )
25
-
26
-
27
- def is_valid_project_root(candidate_path: Path) -> bool:
28
- git_marker_path = candidate_path / ".git"
29
- claude_marker_path = candidate_path / ".claude"
30
- return git_marker_path.exists() or claude_marker_path.exists()
31
-
32
-
33
- def remove_values_from_list(target_list: list[object], values_to_remove: set[str]) -> int:
34
- original_length = len(target_list)
35
- target_list[:] = [
36
- each_value
37
- for each_value in target_list
38
- if not (isinstance(each_value, str) and each_value in values_to_remove)
39
- ]
40
- return original_length - len(target_list)
41
-
42
-
43
- def remove_rules_from_allow_list(
44
- settings: dict[str, object], rules_to_remove: list[str]
45
- ) -> int:
46
- permissions_section = settings.get("permissions")
47
- if not isinstance(permissions_section, dict):
48
- return 0
49
- existing_allow_list = permissions_section.get("allow")
50
- if not isinstance(existing_allow_list, list):
51
- return 0
52
- return remove_values_from_list(existing_allow_list, set(rules_to_remove))
53
-
54
-
55
- def remove_directory_from_additional_directories(
56
- settings: dict[str, object], directory_path: str
57
- ) -> int:
58
- permissions_section = settings.get("permissions")
59
- if not isinstance(permissions_section, dict):
60
- return 0
61
- existing_directories = permissions_section.get("additionalDirectories")
62
- if not isinstance(existing_directories, list):
63
- return 0
64
- return remove_values_from_list(existing_directories, {directory_path})
65
-
66
-
67
- def remove_auto_mode_environment_entry(
68
- settings: dict[str, object], entry_text: str
69
- ) -> int:
70
- auto_mode_section = settings.get("autoMode")
71
- if not isinstance(auto_mode_section, dict):
72
- return 0
73
- existing_environment = auto_mode_section.get("environment")
74
- if not isinstance(existing_environment, list):
75
- return 0
76
- return remove_values_from_list(existing_environment, {entry_text})
77
-
78
-
79
- def prune_settings_after_revoke(settings: dict[str, object]) -> None:
80
- prune_empty_list_then_empty_section(settings, "permissions", "allow")
81
- prune_empty_list_then_empty_section(
82
- settings, "permissions", "additionalDirectories"
83
- )
84
- prune_empty_list_then_empty_section(settings, "autoMode", "environment")
85
-
86
-
87
- def revoke_permissions_for_current_directory() -> None:
88
- claude_user_settings_path: Path = Path.home() / ".claude" / "settings.json"
89
- project_root_path = Path.cwd()
90
- if not is_valid_project_root(project_root_path):
91
- print(
92
- f"ERROR: cwd {project_root_path} is not a project root "
93
- f"(no .git or .claude). Run from a project root.",
94
- file=sys.stderr,
95
- )
96
- raise SystemExit(1)
97
- project_path = get_current_project_path()
98
- permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
99
- environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
100
- project_path=project_path
101
- )
102
- settings = load_settings(claude_user_settings_path)
103
- rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
104
- directories_removed_count = remove_directory_from_additional_directories(
105
- settings, project_path
106
- )
107
- environment_entries_removed_count = remove_auto_mode_environment_entry(
108
- settings, environment_entry
109
- )
110
- total_changes_count = (
111
- rules_removed_count
112
- + directories_removed_count
113
- + environment_entries_removed_count
114
- )
115
- if total_changes_count == 0:
116
- print(f"Project path: {project_path}")
117
- print(f"Settings file: {claude_user_settings_path}")
118
- print("No changes to revoke; settings file left untouched.")
119
- return
120
- prune_settings_after_revoke(settings)
121
- save_settings(claude_user_settings_path, settings)
122
- print(f"Project path: {project_path}")
123
- print(f"Settings file: {claude_user_settings_path}")
124
- print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
125
- print(f"Additional directories removed: {directories_removed_count}")
126
- print(
127
- f"Auto-mode environment entries removed: {environment_entries_removed_count}"
128
- )
129
-
130
-
131
- if __name__ == "__main__":
132
- try:
133
- revoke_permissions_for_current_directory()
134
- except ValueError as path_error:
135
- exit_with_error(str(path_error))
@@ -1,271 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import subprocess
4
- import sys
5
- import unittest.mock
6
- from pathlib import Path
7
- import pytest
8
-
9
- SCRIPT_DIRECTORY = Path(__file__).resolve().parent
10
- if str(SCRIPT_DIRECTORY) not in sys.path:
11
- sys.path.insert(0, str(SCRIPT_DIRECTORY))
12
-
13
- import bugteam_code_rules_gate as gate_module
14
-
15
-
16
- def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
17
- completion = subprocess.run(
18
- ["git", *arguments],
19
- cwd=str(repository_root),
20
- capture_output=True,
21
- text=True,
22
- encoding="utf-8",
23
- errors="replace",
24
- check=True,
25
- )
26
- return completion.stdout
27
-
28
-
29
- def initialize_git_repository(repository_root: Path) -> None:
30
- run_git_in_repository(repository_root, "init", "--initial-branch=main")
31
- run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
32
- run_git_in_repository(repository_root, "config", "user.name", "Test")
33
- run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
34
-
35
-
36
- def commit_all_files(repository_root: Path, commit_message: str) -> None:
37
- run_git_in_repository(repository_root, "add", "-A")
38
- run_git_in_repository(repository_root, "commit", "-m", commit_message)
39
-
40
-
41
- def write_file(file_path: Path, content: str) -> None:
42
- file_path.parent.mkdir(parents=True, exist_ok=True)
43
- file_path.write_text(content, encoding="utf-8")
44
-
45
-
46
- def stage_file(repository_root: Path, relative_path: str) -> None:
47
- run_git_in_repository(repository_root, "add", "--", relative_path)
48
-
49
-
50
- @pytest.fixture()
51
- def temporary_git_repository(tmp_path: Path) -> Path:
52
- repository_root = tmp_path / "repository_under_test"
53
- repository_root.mkdir()
54
- initialize_git_repository(repository_root)
55
- return repository_root
56
-
57
-
58
- def test_paths_from_git_staged_returns_staged_files(
59
- temporary_git_repository: Path,
60
- ) -> None:
61
- write_file(temporary_git_repository / "committed_file.py", "one = 1\n")
62
- commit_all_files(temporary_git_repository, "initial")
63
- write_file(temporary_git_repository / "newly_staged_file.py", "two = 2\n")
64
- write_file(temporary_git_repository / "unstaged_file.py", "three = 3\n")
65
- stage_file(temporary_git_repository, "newly_staged_file.py")
66
-
67
- staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
68
-
69
- staged_names = {path.name for path in staged_paths}
70
- assert "newly_staged_file.py" in staged_names
71
- assert "unstaged_file.py" not in staged_names
72
- assert "committed_file.py" not in staged_names
73
-
74
-
75
- def test_added_lines_for_staged_file_reports_new_lines(
76
- temporary_git_repository: Path,
77
- ) -> None:
78
- write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
79
- commit_all_files(temporary_git_repository, "baseline")
80
- write_file(
81
- temporary_git_repository / "target.py",
82
- "first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
83
- )
84
- stage_file(temporary_git_repository, "target.py")
85
-
86
- added_line_numbers = gate_module.added_lines_for_staged_file(
87
- temporary_git_repository,
88
- "target.py",
89
- )
90
-
91
- assert 3 in added_line_numbers
92
- assert 4 in added_line_numbers
93
- assert 1 not in added_line_numbers
94
- assert 2 not in added_line_numbers
95
-
96
-
97
- def test_added_lines_for_staged_file_treats_new_file_as_fully_added(
98
- temporary_git_repository: Path,
99
- ) -> None:
100
- write_file(temporary_git_repository / "existing.py", "ignored = 0\n")
101
- commit_all_files(temporary_git_repository, "baseline")
102
- write_file(
103
- temporary_git_repository / "brand_new.py",
104
- "alpha = 1\nbeta = 2\ngamma = 3\n",
105
- )
106
- stage_file(temporary_git_repository, "brand_new.py")
107
-
108
- added_line_numbers = gate_module.added_lines_for_staged_file(
109
- temporary_git_repository,
110
- "brand_new.py",
111
- )
112
-
113
- assert added_line_numbers == {1, 2, 3}
114
-
115
-
116
- def test_paths_from_git_staged_uses_null_delimiter(
117
- temporary_git_repository: Path,
118
- ) -> None:
119
- write_file(temporary_git_repository / "first.py", "a = 1\n")
120
- write_file(temporary_git_repository / "second.py", "b = 2\n")
121
- commit_all_files(temporary_git_repository, "baseline")
122
- write_file(temporary_git_repository / "first.py", "a = 10\n")
123
- write_file(temporary_git_repository / "second.py", "b = 20\n")
124
- stage_file(temporary_git_repository, "first.py")
125
- stage_file(temporary_git_repository, "second.py")
126
-
127
- staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
128
-
129
- staged_names = {path.name for path in staged_paths}
130
- assert staged_names == {"first.py", "second.py"}
131
-
132
-
133
- def test_paths_from_git_staged_warns_and_skips_non_utf8_filename(
134
- tmp_path: Path,
135
- capsys: pytest.CaptureFixture[str],
136
- ) -> None:
137
- non_utf8_raw = b"valid.py\x00\xff\xfe_bad.py\x00"
138
- mock_completed = unittest.mock.MagicMock()
139
- mock_completed.returncode = 0
140
- mock_completed.stdout = non_utf8_raw
141
-
142
- with unittest.mock.patch("subprocess.run", return_value=mock_completed):
143
- result_paths = gate_module.paths_from_git_staged(tmp_path)
144
-
145
- captured = capsys.readouterr()
146
- assert "non-UTF-8" in captured.err
147
- assert len(result_paths) == 1
148
- assert result_paths[0].name == "valid.py"
149
-
150
-
151
- def test_staged_added_lines_by_file_maps_every_staged_code_file(
152
- temporary_git_repository: Path,
153
- ) -> None:
154
- write_file(temporary_git_repository / "already_committed.py", "zero = 0\n")
155
- commit_all_files(temporary_git_repository, "initial")
156
- write_file(
157
- temporary_git_repository / "already_committed.py",
158
- "zero = 0\nappended = 1\n",
159
- )
160
- write_file(temporary_git_repository / "added_file.py", "only = 1\n")
161
- stage_file(temporary_git_repository, "already_committed.py")
162
- stage_file(temporary_git_repository, "added_file.py")
163
-
164
- staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
165
- added_lines_map = gate_module.added_lines_by_file_staged(
166
- temporary_git_repository,
167
- staged_paths,
168
- )
169
-
170
- resolved_repository_root = temporary_git_repository.resolve()
171
- assert added_lines_map[resolved_repository_root / "already_committed.py"] == {2}
172
- assert added_lines_map[resolved_repository_root / "added_file.py"] == {1}
173
-
174
-
175
- def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
176
- temporary_git_repository: Path,
177
- monkeypatch: pytest.MonkeyPatch,
178
- ) -> None:
179
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
180
- commit_all_files(temporary_git_repository, "initial")
181
- staged_content_with_banned_identifier = (
182
- "first_value = 1\n"
183
- "def compute_total(operand):\n"
184
- " result = operand + 1\n"
185
- " return result\n"
186
- )
187
- write_file(
188
- temporary_git_repository / "module.py",
189
- staged_content_with_banned_identifier,
190
- )
191
- stage_file(temporary_git_repository, "module.py")
192
-
193
- monkeypatch.chdir(temporary_git_repository)
194
- exit_code = gate_module.main(["--staged"])
195
-
196
- assert exit_code == 1
197
-
198
-
199
- def test_main_staged_mode_passes_when_no_staged_violations(
200
- temporary_git_repository: Path,
201
- monkeypatch: pytest.MonkeyPatch,
202
- ) -> None:
203
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
204
- commit_all_files(temporary_git_repository, "initial")
205
- write_file(
206
- temporary_git_repository / "module.py", "first_value = 1\nsecond_value = 2\n"
207
- )
208
- stage_file(temporary_git_repository, "module.py")
209
-
210
- monkeypatch.chdir(temporary_git_repository)
211
- exit_code = gate_module.main(["--staged"])
212
-
213
- assert exit_code == 0
214
-
215
-
216
- def test_main_staged_mode_exits_zero_when_nothing_staged(
217
- temporary_git_repository: Path,
218
- monkeypatch: pytest.MonkeyPatch,
219
- ) -> None:
220
- write_file(temporary_git_repository / "module.py", "first_value = 1\n")
221
- commit_all_files(temporary_git_repository, "initial")
222
-
223
- monkeypatch.chdir(temporary_git_repository)
224
- exit_code = gate_module.main(["--staged"])
225
-
226
- assert exit_code == 0
227
-
228
-
229
- def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
230
- temporary_git_repository: Path,
231
- ) -> None:
232
- write_file(
233
- temporary_git_repository / "existing.py",
234
- "alpha = 1\nbeta = 2\ngamma = 3\n",
235
- )
236
- commit_all_files(temporary_git_repository, "baseline")
237
- write_file(temporary_git_repository / "existing.py", "alpha = 1\nbeta = 2\n")
238
- stage_file(temporary_git_repository, "existing.py")
239
-
240
- added_line_numbers = gate_module.added_lines_for_staged_file(
241
- temporary_git_repository,
242
- "existing.py",
243
- )
244
-
245
- assert added_line_numbers == set()
246
-
247
-
248
- def test_is_file_absent_in_index_head_does_not_exist_in_module() -> None:
249
- assert not hasattr(gate_module, "is_file_absent_in_index_head")
250
-
251
-
252
- def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
253
- temporary_git_repository: Path,
254
- monkeypatch: pytest.MonkeyPatch,
255
- ) -> None:
256
- write_file(
257
- temporary_git_repository / "sample.py",
258
- "alpha = 1\nbeta = 2\n",
259
- )
260
- commit_all_files(temporary_git_repository, "baseline")
261
- write_file(temporary_git_repository / "sample.py", "alpha = 1\nbeta = 2\ngamma = 3\n")
262
- stage_file(temporary_git_repository, "sample.py")
263
-
264
- monkeypatch.setattr(gate_module, "parse_added_line_numbers", lambda _text: set())
265
-
266
- added_line_numbers = gate_module.added_lines_for_staged_file(
267
- temporary_git_repository,
268
- "sample.py",
269
- )
270
-
271
- assert added_line_numbers == set()
@@ -1,267 +0,0 @@
1
- """Tests for bugteam_fix_hookspath auto-remediation.
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
- """
10
-
11
- from __future__ import annotations
12
-
13
- import importlib.util
14
- import os
15
- import subprocess
16
- from pathlib import Path
17
- from types import ModuleType
18
-
19
- import pytest
20
-
21
-
22
- def _load_fix_module() -> ModuleType:
23
- module_path = Path(__file__).parent / "bugteam_fix_hookspath.py"
24
- spec = importlib.util.spec_from_file_location("bugteam_fix_hookspath", 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
- bugteam_fix_hookspath = _load_fix_module()
33
-
34
-
35
- def _make_isolated_git_environment(home_directory: Path) -> dict[str, str]:
36
- """Build an env dict that pins git's HOME and XDG paths into a tmp directory.
37
-
38
- Without this, real `git config --global` reads/writes hit the developer's
39
- actual ~/.gitconfig — which would corrupt the host machine and make tests
40
- depend on global state. Pointing HOME, USERPROFILE, XDG_CONFIG_HOME, and
41
- GIT_CONFIG_GLOBAL at a temp directory isolates the test fully.
42
- """
43
- isolated_environment = os.environ.copy()
44
- isolated_environment["HOME"] = str(home_directory)
45
- isolated_environment["USERPROFILE"] = str(home_directory)
46
- isolated_environment["XDG_CONFIG_HOME"] = str(home_directory / ".config")
47
- isolated_environment["GIT_CONFIG_GLOBAL"] = str(home_directory / ".gitconfig")
48
- isolated_environment["GIT_CONFIG_NOSYSTEM"] = "1"
49
- return isolated_environment
50
-
51
-
52
- def _initialize_repository(repository_path: Path, environment: dict[str, str]) -> None:
53
- repository_path.mkdir(parents=True, exist_ok=True)
54
- subprocess.run(
55
- ["git", "init", "--quiet", str(repository_path)],
56
- check=True,
57
- env=environment,
58
- )
59
-
60
-
61
- def _set_local_hooks_path(
62
- repository_path: Path,
63
- hooks_path_value: str,
64
- environment: dict[str, str],
65
- ) -> None:
66
- subprocess.run(
67
- [
68
- "git",
69
- "-C",
70
- str(repository_path),
71
- "config",
72
- "--local",
73
- "core.hooksPath",
74
- hooks_path_value,
75
- ],
76
- check=True,
77
- env=environment,
78
- )
79
-
80
-
81
- def _set_global_hooks_path(hooks_path_value: str, environment: dict[str, str]) -> None:
82
- subprocess.run(
83
- ["git", "config", "--global", "core.hooksPath", hooks_path_value],
84
- check=True,
85
- env=environment,
86
- )
87
-
88
-
89
- def _read_local_hooks_path(repository_path: Path, environment: dict[str, str]) -> str:
90
- completed_process = subprocess.run(
91
- [
92
- "git",
93
- "-C",
94
- str(repository_path),
95
- "config",
96
- "--local",
97
- "--get",
98
- "core.hooksPath",
99
- ],
100
- capture_output=True,
101
- text=True,
102
- check=False,
103
- env=environment,
104
- )
105
- return completed_process.stdout.strip()
106
-
107
-
108
- def _read_global_hooks_path(environment: dict[str, str]) -> str:
109
- completed_process = subprocess.run(
110
- ["git", "config", "--global", "--get", "core.hooksPath"],
111
- capture_output=True,
112
- text=True,
113
- check=False,
114
- env=environment,
115
- )
116
- return completed_process.stdout.strip()
117
-
118
-
119
- def _create_canonical_hooks_directory(home_directory: Path) -> Path:
120
- canonical_hooks_directory = home_directory / ".claude" / "hooks" / "git-hooks"
121
- canonical_hooks_directory.mkdir(parents=True)
122
- return canonical_hooks_directory
123
-
124
-
125
- def test_should_remove_local_override_and_pass_preflight(tmp_path: Path) -> None:
126
- home_directory = tmp_path / "home"
127
- home_directory.mkdir()
128
- environment = _make_isolated_git_environment(home_directory)
129
- canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
130
- _set_global_hooks_path(str(canonical_hooks_directory), environment)
131
- repository_path = tmp_path / "synthetic-repo"
132
- _initialize_repository(repository_path, environment)
133
- stale_local_value = str(repository_path / ".git" / "hooks")
134
- _set_local_hooks_path(repository_path, stale_local_value, environment)
135
-
136
- exit_code = bugteam_fix_hookspath.main(
137
- ["--repo-root", str(repository_path)],
138
- environment_overrides=environment,
139
- )
140
-
141
- assert exit_code == 0, (
142
- "fix script must succeed when canonical global hooks dir exists"
143
- )
144
- assert _read_local_hooks_path(repository_path, environment) == "", (
145
- "local core.hooksPath override must be removed"
146
- )
147
-
148
-
149
- def test_should_set_global_hooks_path_when_missing(tmp_path: Path) -> None:
150
- home_directory = tmp_path / "home"
151
- home_directory.mkdir()
152
- environment = _make_isolated_git_environment(home_directory)
153
- canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
154
- repository_path = tmp_path / "synthetic-repo"
155
- _initialize_repository(repository_path, environment)
156
- stale_local_value = str(repository_path / ".git" / "hooks")
157
- _set_local_hooks_path(repository_path, stale_local_value, environment)
158
-
159
- exit_code = bugteam_fix_hookspath.main(
160
- ["--repo-root", str(repository_path)],
161
- environment_overrides=environment,
162
- )
163
-
164
- assert exit_code == 0
165
- global_value_after_fix = _read_global_hooks_path(environment)
166
- assert (
167
- global_value_after_fix.replace("\\", "/")
168
- .rstrip("/")
169
- .endswith("hooks/git-hooks")
170
- ), (
171
- "fix script must set canonical global core.hooksPath when missing; "
172
- f"got '{global_value_after_fix}'"
173
- )
174
-
175
-
176
- def test_should_be_idempotent(tmp_path: Path) -> None:
177
- home_directory = tmp_path / "home"
178
- home_directory.mkdir()
179
- environment = _make_isolated_git_environment(home_directory)
180
- canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
181
- _set_global_hooks_path(str(canonical_hooks_directory), environment)
182
- repository_path = tmp_path / "synthetic-repo"
183
- _initialize_repository(repository_path, environment)
184
- stale_local_value = str(repository_path / ".git" / "hooks")
185
- _set_local_hooks_path(repository_path, stale_local_value, environment)
186
-
187
- first_exit_code = bugteam_fix_hookspath.main(
188
- ["--repo-root", str(repository_path)],
189
- environment_overrides=environment,
190
- )
191
- second_exit_code = bugteam_fix_hookspath.main(
192
- ["--repo-root", str(repository_path)],
193
- environment_overrides=environment,
194
- )
195
-
196
- assert first_exit_code == 0
197
- assert second_exit_code == 0, "second invocation must succeed without errors"
198
- assert _read_local_hooks_path(repository_path, environment) == ""
199
-
200
-
201
- def test_should_no_op_when_already_clean(tmp_path: Path) -> None:
202
- home_directory = tmp_path / "home"
203
- home_directory.mkdir()
204
- environment = _make_isolated_git_environment(home_directory)
205
- canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
206
- _set_global_hooks_path(str(canonical_hooks_directory), environment)
207
- repository_path = tmp_path / "synthetic-repo"
208
- _initialize_repository(repository_path, environment)
209
-
210
- exit_code = bugteam_fix_hookspath.main(
211
- ["--repo-root", str(repository_path)],
212
- environment_overrides=environment,
213
- )
214
-
215
- assert exit_code == 0
216
- assert _read_local_hooks_path(repository_path, environment) == ""
217
- assert (
218
- _read_global_hooks_path(environment)
219
- .replace("\\", "/")
220
- .rstrip("/")
221
- .endswith("hooks/git-hooks")
222
- )
223
-
224
-
225
- def test_should_exit_nonzero_when_canonical_hooks_directory_missing(
226
- tmp_path: Path,
227
- capsys: pytest.CaptureFixture[str],
228
- ) -> None:
229
- home_directory = tmp_path / "home"
230
- home_directory.mkdir()
231
- environment = _make_isolated_git_environment(home_directory)
232
- repository_path = tmp_path / "synthetic-repo"
233
- _initialize_repository(repository_path, environment)
234
- stale_local_value = str(repository_path / ".git" / "hooks")
235
- _set_local_hooks_path(repository_path, stale_local_value, environment)
236
-
237
- exit_code = bugteam_fix_hookspath.main(
238
- ["--repo-root", str(repository_path)],
239
- environment_overrides=environment,
240
- )
241
-
242
- assert exit_code != 0, (
243
- "fix script must fail clearly when ~/.claude/hooks/git-hooks does not exist "
244
- "so the user knows to run `npx claude-dev-env .`"
245
- )
246
- captured_streams = capsys.readouterr()
247
- assert "hooks/git-hooks" in captured_streams.err.replace("\\", "/")
248
-
249
-
250
- def test_should_handle_paths_with_spaces(tmp_path: Path) -> None:
251
- home_directory = tmp_path / "home with space"
252
- home_directory.mkdir()
253
- environment = _make_isolated_git_environment(home_directory)
254
- canonical_hooks_directory = _create_canonical_hooks_directory(home_directory)
255
- _set_global_hooks_path(str(canonical_hooks_directory), environment)
256
- repository_path = tmp_path / "repo with space"
257
- _initialize_repository(repository_path, environment)
258
- stale_local_value = str(repository_path / ".git" / "hooks")
259
- _set_local_hooks_path(repository_path, stale_local_value, environment)
260
-
261
- exit_code = bugteam_fix_hookspath.main(
262
- ["--repo-root", str(repository_path)],
263
- environment_overrides=environment,
264
- )
265
-
266
- assert exit_code == 0
267
- assert _read_local_hooks_path(repository_path, environment) == ""