claude-dev-env 2.18.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +105 -75
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +6 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +9 -5
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/json_file_reader.py +22 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/pending_sidecars.py +33 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Production-neutral fixtures for session-edit stage gate tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_SUPPORT_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
+
_HOOKS_TREE = _SUPPORT_DIRECTORY.parent
|
|
13
|
+
if str(_HOOKS_TREE) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(_HOOKS_TREE))
|
|
15
|
+
|
|
16
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
17
|
+
ALL_EDITED_FILE_PATHS_KEY,
|
|
18
|
+
SESSION_EDIT_FILE_PREFIX,
|
|
19
|
+
SESSION_EDIT_FILE_SUFFIX,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def clean_git_environment() -> dict[str, str]:
|
|
24
|
+
"""Return the environment without inherited repository overrides."""
|
|
25
|
+
return {
|
|
26
|
+
each_key: each_value
|
|
27
|
+
for each_key, each_value in os.environ.items()
|
|
28
|
+
if not each_key.startswith("GIT_")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_git(repository_root: Path, *git_arguments: str) -> None:
|
|
33
|
+
"""Run one Git command in a test repository."""
|
|
34
|
+
subprocess.run(
|
|
35
|
+
["git", "-C", str(repository_root), *git_arguments],
|
|
36
|
+
check=True,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
env=clean_git_environment(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def initialize_repository(repository_root: Path) -> None:
|
|
43
|
+
"""Create a Git repository on a non-protected test branch."""
|
|
44
|
+
run_git(repository_root, "init")
|
|
45
|
+
run_git(repository_root, "checkout", "-b", "test-session-stage")
|
|
46
|
+
run_git(repository_root, "config", "user.email", "tests@example.com")
|
|
47
|
+
run_git(repository_root, "config", "user.name", "Gate Tests")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def prepare_repository_with_unstaged_edit(repository_root: Path) -> Path:
|
|
51
|
+
"""Create one tracked file with an unstaged edit."""
|
|
52
|
+
initialize_repository(repository_root)
|
|
53
|
+
tracked_file = repository_root / "widget.py"
|
|
54
|
+
tracked_file.write_text("x = 1\n", encoding="utf-8")
|
|
55
|
+
run_git(repository_root, "add", "widget.py")
|
|
56
|
+
run_git(repository_root, "commit", "-m", "add widget")
|
|
57
|
+
tracked_file.write_text("x = 2\n", encoding="utf-8")
|
|
58
|
+
return tracked_file
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def prepare_repository_with_two_unstaged_edits(repository_root: Path) -> tuple[Path, Path]:
|
|
62
|
+
"""Create two tracked files with separate unstaged edits."""
|
|
63
|
+
initialize_repository(repository_root)
|
|
64
|
+
tracked_file = repository_root / "widget.py"
|
|
65
|
+
second_file = repository_root / "second_widget.py"
|
|
66
|
+
tracked_file.write_text("x = 1\n", encoding="utf-8")
|
|
67
|
+
second_file.write_text("y = 1\n", encoding="utf-8")
|
|
68
|
+
run_git(repository_root, "add", "widget.py", "second_widget.py")
|
|
69
|
+
run_git(repository_root, "commit", "-m", "add widgets")
|
|
70
|
+
tracked_file.write_text("x = 2\n", encoding="utf-8")
|
|
71
|
+
second_file.write_text("y = 2\n", encoding="utf-8")
|
|
72
|
+
return tracked_file, second_file
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def make_test_directories(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
76
|
+
"""Create repository, tracker, and home directories for one test."""
|
|
77
|
+
repository_root = tmp_path / "repo"
|
|
78
|
+
repository_root.mkdir()
|
|
79
|
+
temp_directory = tmp_path / "tmp"
|
|
80
|
+
temp_directory.mkdir()
|
|
81
|
+
home_directory = tmp_path / "home"
|
|
82
|
+
home_directory.mkdir()
|
|
83
|
+
return repository_root, temp_directory, home_directory
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_process_repository(tmp_path: Path) -> Path:
|
|
87
|
+
"""Create a separate Git repository for the dispatcher process directory."""
|
|
88
|
+
process_repository = tmp_path / "process-repository"
|
|
89
|
+
process_repository.mkdir()
|
|
90
|
+
initialize_repository(process_repository)
|
|
91
|
+
return process_repository
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def prepare_process_child_repository(process_repository: Path) -> None:
|
|
95
|
+
"""Create the relative command directory inside process repository B."""
|
|
96
|
+
process_child_repository = process_repository / "child"
|
|
97
|
+
process_child_repository.mkdir()
|
|
98
|
+
initialize_repository(process_child_repository)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_hook_environment(
|
|
102
|
+
temp_directory: Path,
|
|
103
|
+
home_directory: Path,
|
|
104
|
+
) -> dict[str, str]:
|
|
105
|
+
"""Build isolated environment variables for production hooks."""
|
|
106
|
+
environment = clean_git_environment()
|
|
107
|
+
environment["TMP"] = str(temp_directory)
|
|
108
|
+
environment["TEMP"] = str(temp_directory)
|
|
109
|
+
environment["TMPDIR"] = str(temp_directory)
|
|
110
|
+
environment["USERPROFILE"] = str(home_directory)
|
|
111
|
+
environment["HOME"] = str(home_directory)
|
|
112
|
+
return environment
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_production_hook(
|
|
116
|
+
hook_path: Path,
|
|
117
|
+
payload: str,
|
|
118
|
+
repository_root: Path,
|
|
119
|
+
temp_directory: Path,
|
|
120
|
+
home_directory: Path,
|
|
121
|
+
process_directory: Path | None = None,
|
|
122
|
+
) -> subprocess.CompletedProcess[str]:
|
|
123
|
+
"""Run one hook script as a production subprocess."""
|
|
124
|
+
return subprocess.run(
|
|
125
|
+
[sys.executable, str(hook_path)],
|
|
126
|
+
check=False,
|
|
127
|
+
input=payload,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
cwd=str(process_directory or repository_root),
|
|
131
|
+
env=build_hook_environment(temp_directory, home_directory),
|
|
132
|
+
timeout=60,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_tracker_event(
|
|
137
|
+
tracked_file: Path,
|
|
138
|
+
session_id: str,
|
|
139
|
+
repository_root: Path,
|
|
140
|
+
temp_directory: Path,
|
|
141
|
+
home_directory: Path,
|
|
142
|
+
) -> subprocess.CompletedProcess[str]:
|
|
143
|
+
"""Run the production PostToolUse tracker for one edited file."""
|
|
144
|
+
payload = json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"tool_name": "Edit",
|
|
147
|
+
"session_id": session_id,
|
|
148
|
+
"tool_input": {"file_path": str(tracked_file)},
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
return run_production_hook(
|
|
152
|
+
_HOOKS_TREE / "observability" / "session_file_edit_tracker.py",
|
|
153
|
+
payload,
|
|
154
|
+
repository_root,
|
|
155
|
+
temp_directory,
|
|
156
|
+
home_directory,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_bash_dispatcher(
|
|
161
|
+
bash_command: str,
|
|
162
|
+
session_id: str,
|
|
163
|
+
repository_root: Path,
|
|
164
|
+
temp_directory: Path,
|
|
165
|
+
home_directory: Path,
|
|
166
|
+
process_directory: Path | None = None,
|
|
167
|
+
) -> subprocess.CompletedProcess[str]:
|
|
168
|
+
"""Run the production Bash PreToolUse dispatcher for one command."""
|
|
169
|
+
payload = json.dumps(
|
|
170
|
+
{
|
|
171
|
+
"tool_name": "Bash",
|
|
172
|
+
"cwd": str(repository_root),
|
|
173
|
+
"session_id": session_id,
|
|
174
|
+
"tool_input": {"command": bash_command},
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
return run_production_hook(
|
|
178
|
+
_SUPPORT_DIRECTORY / "bash_pre_tool_use_dispatcher.py",
|
|
179
|
+
payload,
|
|
180
|
+
repository_root,
|
|
181
|
+
temp_directory,
|
|
182
|
+
home_directory,
|
|
183
|
+
process_directory,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def read_tracker_paths(temp_directory: Path, session_id: str) -> list[str]:
|
|
188
|
+
"""Read the recorded paths from one production tracker file."""
|
|
189
|
+
tracker_file = (
|
|
190
|
+
temp_directory / f"{SESSION_EDIT_FILE_PREFIX}{session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
191
|
+
)
|
|
192
|
+
tracker_payload = json.loads(tracker_file.read_text(encoding="utf-8"))
|
|
193
|
+
return tracker_payload[ALL_EDITED_FILE_PATHS_KEY]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_dispatcher_decision(stdout_text: str) -> tuple[str, str]:
|
|
197
|
+
"""Return the dispatcher decision and reason from its stdout payload."""
|
|
198
|
+
if not stdout_text.strip():
|
|
199
|
+
return "", ""
|
|
200
|
+
hook_specific_output = json.loads(stdout_text)["hookSpecificOutput"]
|
|
201
|
+
return (
|
|
202
|
+
hook_specific_output.get("permissionDecision", ""),
|
|
203
|
+
hook_specific_output.get("permissionDecisionReason", ""),
|
|
204
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Production-path tracker event-order tests for the session-edit stage gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_TEST_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(_TEST_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_TEST_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
from test_session_edit_stage_gate_support import ( # noqa: E402
|
|
15
|
+
make_test_directories,
|
|
16
|
+
make_process_repository,
|
|
17
|
+
parse_dispatcher_decision,
|
|
18
|
+
prepare_process_child_repository,
|
|
19
|
+
prepare_repository_with_unstaged_edit,
|
|
20
|
+
prepare_repository_with_two_unstaged_edits,
|
|
21
|
+
read_tracker_paths,
|
|
22
|
+
run_bash_dispatcher,
|
|
23
|
+
run_tracker_event,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SESSION_ID = "tracker-production-session"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_tracker_events_precede_dispatcher_stage_decision(tmp_path: Path) -> None:
|
|
30
|
+
"""The dispatcher sees every edit recorded before the commit event."""
|
|
31
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
32
|
+
tracked_file, second_file = prepare_repository_with_two_unstaged_edits(repository_root)
|
|
33
|
+
for each_file in (tracked_file, second_file):
|
|
34
|
+
tracker_run = run_tracker_event(
|
|
35
|
+
each_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
36
|
+
)
|
|
37
|
+
assert tracker_run.returncode == 0
|
|
38
|
+
|
|
39
|
+
assert read_tracker_paths(temp_directory, SESSION_ID) == [
|
|
40
|
+
str(tracked_file.resolve()),
|
|
41
|
+
str(second_file.resolve()),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
dispatcher_run = run_bash_dispatcher(
|
|
45
|
+
"git commit -m update",
|
|
46
|
+
SESSION_ID,
|
|
47
|
+
repository_root,
|
|
48
|
+
temp_directory,
|
|
49
|
+
home_directory,
|
|
50
|
+
)
|
|
51
|
+
assert dispatcher_run.returncode == 0
|
|
52
|
+
decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
|
|
53
|
+
assert decision == "deny"
|
|
54
|
+
assert "widget.py" in reason
|
|
55
|
+
assert "second_widget.py" in reason
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_dispatcher_uses_event_cwd_when_process_runs_elsewhere(tmp_path: Path) -> None:
|
|
59
|
+
"""The dispatcher passes event cwd so the gate inspects the event repository."""
|
|
60
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
61
|
+
process_repository = make_process_repository(tmp_path)
|
|
62
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
63
|
+
tracker_run = run_tracker_event(
|
|
64
|
+
tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
65
|
+
)
|
|
66
|
+
assert tracker_run.returncode == 0
|
|
67
|
+
|
|
68
|
+
dispatcher_run = run_bash_dispatcher(
|
|
69
|
+
"git commit -m update",
|
|
70
|
+
SESSION_ID,
|
|
71
|
+
repository_root,
|
|
72
|
+
temp_directory,
|
|
73
|
+
home_directory,
|
|
74
|
+
process_directory=process_repository,
|
|
75
|
+
)
|
|
76
|
+
assert dispatcher_run.returncode == 0
|
|
77
|
+
decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
|
|
78
|
+
assert decision == "deny"
|
|
79
|
+
assert "widget.py" in reason
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
"bash_command",
|
|
84
|
+
[
|
|
85
|
+
"cd child && git commit -m update",
|
|
86
|
+
"pushd child && git commit -m update",
|
|
87
|
+
"git -C child commit -m update",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
def test_dispatcher_anchors_relative_command_directory_to_event_cwd(
|
|
91
|
+
tmp_path: Path,
|
|
92
|
+
bash_command: str,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Relative commit directories resolve from event cwd across command forms."""
|
|
95
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
96
|
+
process_repository = make_process_repository(tmp_path)
|
|
97
|
+
prepare_process_child_repository(process_repository)
|
|
98
|
+
(repository_root / "child").mkdir()
|
|
99
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
100
|
+
tracker_run = run_tracker_event(
|
|
101
|
+
tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
102
|
+
)
|
|
103
|
+
assert tracker_run.returncode == 0
|
|
104
|
+
|
|
105
|
+
dispatcher_run = run_bash_dispatcher(
|
|
106
|
+
bash_command,
|
|
107
|
+
SESSION_ID,
|
|
108
|
+
repository_root,
|
|
109
|
+
temp_directory,
|
|
110
|
+
home_directory,
|
|
111
|
+
process_directory=process_repository,
|
|
112
|
+
)
|
|
113
|
+
assert dispatcher_run.returncode == 0
|
|
114
|
+
decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
|
|
115
|
+
assert decision == "deny"
|
|
116
|
+
assert "widget.py" in reason
|
|
@@ -26,6 +26,13 @@ from pathlib import Path
|
|
|
26
26
|
import pytest
|
|
27
27
|
|
|
28
28
|
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
29
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
31
|
+
|
|
32
|
+
from test_hook_subprocess_support import ( # noqa: E402
|
|
33
|
+
assert_hook_deny_log_contains,
|
|
34
|
+
run_hook_as_subprocess,
|
|
35
|
+
)
|
|
29
36
|
|
|
30
37
|
ALL_CONVERTED_HOOK_FILENAMES = (
|
|
31
38
|
"open_questions_in_plans_blocker.py",
|
|
@@ -48,15 +55,15 @@ ALL_FAIL_SOFT_PAYLOADS = (
|
|
|
48
55
|
)
|
|
49
56
|
|
|
50
57
|
|
|
51
|
-
def _run_hook_script(
|
|
58
|
+
def _run_hook_script(
|
|
59
|
+
hook_filename: str, stdin_text: str, temporary_home_directory: Path
|
|
60
|
+
) -> subprocess.CompletedProcess[str]:
|
|
52
61
|
hook_script_path = _BLOCKING_DIRECTORY / hook_filename
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
check=False,
|
|
59
|
-
cwd=str(Path.home()),
|
|
62
|
+
return run_hook_as_subprocess(
|
|
63
|
+
hook_script_path=hook_script_path,
|
|
64
|
+
payload_text=stdin_text,
|
|
65
|
+
working_directory=temporary_home_directory,
|
|
66
|
+
home_directory=temporary_home_directory,
|
|
60
67
|
)
|
|
61
68
|
|
|
62
69
|
|
|
@@ -69,8 +76,10 @@ def _decision_from_stdout(completed: subprocess.CompletedProcess) -> str | None:
|
|
|
69
76
|
|
|
70
77
|
@pytest.mark.parametrize("hook_filename", ALL_CONVERTED_HOOK_FILENAMES)
|
|
71
78
|
@pytest.mark.parametrize("stdin_text", ALL_FAIL_SOFT_PAYLOADS)
|
|
72
|
-
def test_fail_soft_payload_exits_zero_with_no_decision(
|
|
73
|
-
|
|
79
|
+
def test_fail_soft_payload_exits_zero_with_no_decision(
|
|
80
|
+
hook_filename: str, stdin_text: str, tmp_path: Path
|
|
81
|
+
) -> None:
|
|
82
|
+
completed = _run_hook_script(hook_filename, stdin_text, tmp_path)
|
|
74
83
|
assert completed.returncode == 0, (
|
|
75
84
|
f"{hook_filename} must exit zero on fail-soft stdin; "
|
|
76
85
|
f"got code {completed.returncode}, stderr {completed.stderr!r}"
|
|
@@ -93,9 +102,10 @@ def test_open_questions_blocker_still_denies_plan_with_open_questions(
|
|
|
93
102
|
"tool_input": {"file_path": str(plan_path), "content": plan_body},
|
|
94
103
|
}
|
|
95
104
|
)
|
|
96
|
-
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
|
|
97
|
-
assert completed.returncode == 0
|
|
105
|
+
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload, tmp_path)
|
|
106
|
+
assert completed.returncode == 0, completed.stderr
|
|
98
107
|
assert _decision_from_stdout(completed) == "deny"
|
|
108
|
+
assert_hook_deny_log_contains(tmp_path, "open_questions_in_plans_blocker.py")
|
|
99
109
|
|
|
100
110
|
|
|
101
111
|
def test_open_questions_blocker_still_allows_plan_without_open_questions(
|
|
@@ -111,8 +121,8 @@ def test_open_questions_blocker_still_allows_plan_without_open_questions(
|
|
|
111
121
|
"tool_input": {"file_path": str(plan_path), "content": plan_body},
|
|
112
122
|
}
|
|
113
123
|
)
|
|
114
|
-
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
|
|
115
|
-
assert completed.returncode == 0
|
|
124
|
+
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload, tmp_path)
|
|
125
|
+
assert completed.returncode == 0, completed.stderr
|
|
116
126
|
assert _decision_from_stdout(completed) is None
|
|
117
127
|
|
|
118
128
|
|
|
@@ -130,9 +140,10 @@ def test_package_inventory_blocker_still_denies_uninventoried_new_file(
|
|
|
130
140
|
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
131
141
|
}
|
|
132
142
|
)
|
|
133
|
-
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
134
|
-
assert completed.returncode == 0
|
|
143
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload, tmp_path)
|
|
144
|
+
assert completed.returncode == 0, completed.stderr
|
|
135
145
|
assert _decision_from_stdout(completed) == "deny"
|
|
146
|
+
assert_hook_deny_log_contains(tmp_path, "package_inventory_stale_blocker.py")
|
|
136
147
|
|
|
137
148
|
|
|
138
149
|
def test_package_inventory_blocker_still_allows_inventoried_new_file(
|
|
@@ -152,15 +163,15 @@ def test_package_inventory_blocker_still_allows_inventoried_new_file(
|
|
|
152
163
|
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
153
164
|
}
|
|
154
165
|
)
|
|
155
|
-
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
156
|
-
assert completed.returncode == 0
|
|
166
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload, tmp_path)
|
|
167
|
+
assert completed.returncode == 0, completed.stderr
|
|
157
168
|
assert _decision_from_stdout(completed) is None
|
|
158
169
|
|
|
159
170
|
|
|
160
|
-
def test_converted_hooks_allow_unrelated_tool_name() -> None:
|
|
171
|
+
def test_converted_hooks_allow_unrelated_tool_name(tmp_path: Path) -> None:
|
|
161
172
|
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
|
162
173
|
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|
|
163
|
-
completed = _run_hook_script(each_hook_filename, payload)
|
|
174
|
+
completed = _run_hook_script(each_hook_filename, payload, tmp_path)
|
|
164
175
|
assert completed.returncode == 0, (
|
|
165
176
|
f"{each_hook_filename} must exit zero on an unrelated tool; stderr {completed.stderr!r}"
|
|
166
177
|
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Production-path tests for pytest-Django and Playwright preflight selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
_DISPATCHER_PATH = _BLOCKING_DIRECTORY / "bash_pre_tool_use_dispatcher.py"
|
|
11
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
from test_hook_subprocess_support import ( # noqa: E402
|
|
15
|
+
build_bash_payload,
|
|
16
|
+
read_hook_permission_decision,
|
|
17
|
+
run_hook_as_subprocess,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_bash_dispatch(
|
|
22
|
+
command: str, project_root: Path
|
|
23
|
+
) -> subprocess.CompletedProcess[str]:
|
|
24
|
+
return run_hook_as_subprocess(
|
|
25
|
+
hook_script_path=_DISPATCHER_PATH,
|
|
26
|
+
payload_text=build_bash_payload(command),
|
|
27
|
+
working_directory=project_root,
|
|
28
|
+
home_directory=project_root,
|
|
29
|
+
environment_updates_by_name={"PWD": str(project_root)},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _create_django_project_fixture(tmp_path: Path) -> Path:
|
|
34
|
+
project_root = tmp_path / "django-project"
|
|
35
|
+
project_root.mkdir()
|
|
36
|
+
(project_root / "manage.py").write_text(
|
|
37
|
+
"import sys\n\nif __name__ == '__main__':\n sys.exit(0)\n",
|
|
38
|
+
encoding="utf-8",
|
|
39
|
+
)
|
|
40
|
+
return project_root
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_dispatcher_selects_pytest_django_changed_test(tmp_path: Path) -> None:
|
|
44
|
+
project_root = _create_django_project_fixture(tmp_path)
|
|
45
|
+
|
|
46
|
+
command = "python -m pytest tests/test_models.py"
|
|
47
|
+
completed_process = _run_bash_dispatch(command, project_root)
|
|
48
|
+
|
|
49
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
50
|
+
decision, reason = read_hook_permission_decision(completed_process.stdout)
|
|
51
|
+
assert decision == "deny"
|
|
52
|
+
assert "No database file (db.sqlite3)" in reason
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_dispatcher_ignores_unrecognized_django_test_runner(tmp_path: Path) -> None:
|
|
56
|
+
project_root = _create_django_project_fixture(tmp_path)
|
|
57
|
+
|
|
58
|
+
command = "python -m unittest tests/test_models.py"
|
|
59
|
+
completed_process = _run_bash_dispatch(command, project_root)
|
|
60
|
+
|
|
61
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
62
|
+
assert completed_process.stdout == ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_dispatcher_selects_playwright_changed_spec(tmp_path: Path) -> None:
|
|
66
|
+
command = "npx playwright test tests/account.spec.ts --base-url http://127.0.0.1:1"
|
|
67
|
+
completed_process = _run_bash_dispatch(command, tmp_path)
|
|
68
|
+
|
|
69
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
70
|
+
decision, reason = read_hook_permission_decision(completed_process.stdout)
|
|
71
|
+
assert decision == "deny"
|
|
72
|
+
assert "Server at http://127.0.0.1:1 is unreachable" in reason
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_dispatcher_ignores_non_playwright_test_runner(tmp_path: Path) -> None:
|
|
76
|
+
command = "node tests/account.spec.ts"
|
|
77
|
+
completed_process = _run_bash_dispatch(command, tmp_path)
|
|
78
|
+
|
|
79
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
80
|
+
assert completed_process.stdout == ""
|
|
@@ -6,7 +6,7 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
|
|
|
6
6
|
|
|
7
7
|
| File | Git hook | What it does |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| `pre_commit.py` | `pre-commit` | Runs
|
|
9
|
+
| `pre_commit.py` | `pre-commit` | Runs immediate CODE_RULES and terminology validation over staged changes; exits 1 when any staged file has a blocking violation. CI runs package tests for package Python changes. |
|
|
10
10
|
| `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate. An existing branch's gate base is the merge base with the remote default branch; the gate process still diffs that base against checkout HEAD, so the surface matches the pushed tip only when HEAD is that tip. |
|
|
11
11
|
| `pre_push_base_reference.py` | — | Resolves a usable gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then turns a symbolic default-branch head into a reference that git can resolve |
|
|
12
12
|
| `post_commit.py` | `post-commit` | Runs after a commit lands; performs any post-commit bookkeeping |
|
|
@@ -10,6 +10,7 @@ import ...`` resolve against this file both inside the repo and under
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
STAGED_SCOPE_ARGUMENT: str = "--staged"
|
|
13
|
+
IMMEDIATE_SCOPE_ARGUMENT: str = "--immediate"
|
|
13
14
|
BASE_REFERENCE_ARGUMENT: str = "--base"
|
|
14
15
|
DEFAULT_REMOTE_BASE_REFERENCE: str = "origin/HEAD"
|
|
15
16
|
ALL_ZEROS_OBJECT_NAME_CHARACTER: str = "0"
|