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.
- package/agents/clean-coder.md +109 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +451 -39
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +182 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
- package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
- package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
- package/hooks/blocking/windows_rmtree_blocker.py +23 -6
- package/hooks/config/banned_identifiers_constants.py +24 -0
- package/hooks/config/hardcoded_user_path_constants.py +12 -0
- package/hooks/config/hook_log_extractor_constants.py +1 -1
- package/hooks/config/pre_tool_use_stdin.py +48 -0
- package/hooks/config/setup_project_paths_constants.py +4 -0
- package/hooks/config/stuttering_check_config.py +14 -0
- package/hooks/config/stuttering_import_binding_constants.py +11 -0
- package/hooks/config/sys_path_insert_constants.py +4 -0
- package/hooks/config/test_banned_identifiers_constants.py +48 -0
- package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
- package/hooks/config/test_hook_log_extractor_constants.py +3 -3
- package/hooks/config/test_pre_tool_use_stdin.py +80 -0
- package/hooks/config/unused_module_import_constants.py +7 -0
- package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
- package/hooks/git-hooks/config.py +3 -3
- package/hooks/git-hooks/test_gate_utils.py +10 -10
- package/hooks/mypy.ini +2 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +125 -0
- package/skills/bugteam/CONSTRAINTS.md +12 -6
- package/skills/bugteam/SKILL.md +364 -154
- package/skills/bugteam/SKILL_EVALS.md +25 -23
- package/skills/bugteam/reference/README.md +2 -0
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
- package/skills/bugteam/test_skill_additions.py +13 -4
- package/skills/bugteam/test_team_lifecycle.py +103 -0
- package/skills/findbugs/SKILL.md +3 -3
- package/skills/fixbugs/SKILL.md +4 -4
- package/skills/monitor-open-prs/SKILL.md +32 -2
- package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
- package/skills/pr-converge/SKILL.md +1206 -131
- package/skills/pr-converge/scripts/README.md +145 -0
- package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
- package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
- package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
- package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
- package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
- package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
- package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
- package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
- package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
- package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
- package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
- package/skills/pr-converge/scripts/view_pr_context.py +47 -0
- package/skills/pr-converge/test_team_lifecycle.py +56 -0
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
- package/skills/qbug/SKILL.md +4 -4
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
- package/skills/resume-review/SKILL.md +261 -0
- package/skills/bugteam/scripts/README.md +0 -58
- package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
- package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
- package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
- package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
- /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) == ""
|