claude-dev-env 2.17.0 → 2.19.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 +89 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/orchestrator/SKILL.md +6 -4
- package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
- package/.agents/skills/pr-cleanup/SKILL.md +17 -85
- 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/team-advisor/SKILL.md +5 -5
- 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/advisor/AGENTS.md +5 -5
- package/_shared/advisor/advisor-protocol.md +54 -31
- package/_shared/advisor/reference/advisor-block.md +5 -1
- package/_shared/advisor/reference/consult-format.md +1 -1
- package/_shared/advisor/reference/identity.md +28 -0
- package/_shared/advisor/reference/lifecycle.md +8 -1
- package/_shared/advisor/reference/sol-rung.md +12 -7
- package/_shared/advisor/reference/spawn-walk-log.md +6 -5
- package/_shared/advisor/reference/third-party-bind.md +5 -6
- package/_shared/advisor/reference/warm-up.md +9 -2
- package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
- package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
- package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
- package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
- package/_shared/advisor/scripts/tier_model_ids.py +75 -7
- 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_parts/tests/test_gate_running.py +47 -4
- package/_shared/pr-loop/worker-spawn.md +4 -3
- 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 +4 -0
- package/docs/references/team-advisor-skill.md +2 -2
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- 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_claude_md_orphan_file_blocker.py +171 -1
- 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 +308 -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_deny_mode.py +42 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- 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 +168 -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/pre_commit.py +6 -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/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/sensitive_file_protector_constants.py +2 -4
- package/hooks/json_file_reader.py +22 -0
- package/hooks/pending_sidecars.py +33 -0
- 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/package.json +1 -1
- /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,64 @@
|
|
|
1
|
+
"""Production-path staging tests for the session-edit stage gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_TEST_DIRECTORY = Path(__file__).resolve().parent
|
|
9
|
+
if str(_TEST_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_TEST_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
from test_session_edit_stage_gate_support import ( # noqa: E402
|
|
13
|
+
make_test_directories,
|
|
14
|
+
parse_dispatcher_decision,
|
|
15
|
+
prepare_repository_with_unstaged_edit,
|
|
16
|
+
run_bash_dispatcher,
|
|
17
|
+
run_tracker_event,
|
|
18
|
+
read_tracker_paths,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SESSION_ID = "staging-production-session"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dispatcher_denies_tracker_recorded_unstaged_edit(tmp_path: Path) -> None:
|
|
25
|
+
"""The Bash dispatcher denies a commit for a tracker-recorded edit."""
|
|
26
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
27
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
28
|
+
tracker_run = run_tracker_event(
|
|
29
|
+
tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
30
|
+
)
|
|
31
|
+
assert tracker_run.returncode == 0
|
|
32
|
+
assert read_tracker_paths(temp_directory, SESSION_ID) == [str(tracked_file.resolve())]
|
|
33
|
+
|
|
34
|
+
dispatcher_run = run_bash_dispatcher(
|
|
35
|
+
"git commit -m update",
|
|
36
|
+
SESSION_ID,
|
|
37
|
+
repository_root,
|
|
38
|
+
temp_directory,
|
|
39
|
+
home_directory,
|
|
40
|
+
)
|
|
41
|
+
assert dispatcher_run.returncode == 0
|
|
42
|
+
decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
|
|
43
|
+
assert decision == "deny"
|
|
44
|
+
assert "widget.py" in reason
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_dispatcher_allows_specific_staging_segment(tmp_path: Path) -> None:
|
|
48
|
+
"""The Bash dispatcher accepts a commit preceded by a specific git add."""
|
|
49
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
50
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
51
|
+
tracker_run = run_tracker_event(
|
|
52
|
+
tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
53
|
+
)
|
|
54
|
+
assert tracker_run.returncode == 0
|
|
55
|
+
|
|
56
|
+
dispatcher_run = run_bash_dispatcher(
|
|
57
|
+
"git add widget.py && git commit -m update",
|
|
58
|
+
SESSION_ID,
|
|
59
|
+
repository_root,
|
|
60
|
+
temp_directory,
|
|
61
|
+
home_directory,
|
|
62
|
+
)
|
|
63
|
+
assert dispatcher_run.returncode == 0
|
|
64
|
+
assert parse_dispatcher_decision(dispatcher_run.stdout) == ("", "")
|
|
@@ -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 == ""
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Git pre-commit
|
|
2
|
+
"""Native Git pre-commit owner for staged CODE_RULES validation.
|
|
3
3
|
|
|
4
4
|
Installed to the user's shared git-hooks directory via the claude-dev-env
|
|
5
5
|
installer; git invokes this file as `pre-commit` (the installer strips the
|
|
6
6
|
`_` and `.py` suffix when copying into the live hooks path).
|
|
7
7
|
|
|
8
|
+
The Agent Bash commit surface records passive staged-surface evidence. This
|
|
9
|
+
native hook invokes the shared gate and owns the staged commit decision.
|
|
10
|
+
|
|
8
11
|
Exit codes:
|
|
9
12
|
0 - staged changes pass the gate (or the gate is not installed locally).
|
|
10
13
|
1 - staged changes introduce one or more blocking violations.
|
|
@@ -17,16 +20,17 @@ import subprocess
|
|
|
17
20
|
import sys
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
|
|
23
|
+
from gate_utils import is_safe_regular_file, resolve_gate_script_path
|
|
20
24
|
from git_hooks_constants import (
|
|
21
25
|
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
|
|
22
26
|
GATE_SCRIPT_NOT_FOUND_MESSAGE,
|
|
23
27
|
INVOKE_GATE_FAILURE_MESSAGE,
|
|
24
28
|
STAGED_SCOPE_ARGUMENT,
|
|
25
29
|
)
|
|
26
|
-
from gate_utils import is_safe_regular_file, resolve_gate_script_path
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
def invoke_gate(gate_script_path: Path) -> int:
|
|
33
|
+
"""Invoke the shared staged gate and return its exit code."""
|
|
30
34
|
staged_scope_argument = STAGED_SCOPE_ARGUMENT
|
|
31
35
|
invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
|
|
32
36
|
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
@@ -1567,6 +1567,63 @@ def test_main_reads_default_branch_tracking_reference_when_origin_head_is_unset(
|
|
|
1567
1567
|
assert exit_code == 0
|
|
1568
1568
|
|
|
1569
1569
|
|
|
1570
|
+
def test_main_selects_stdin_bases_for_new_and_existing_branch_pushes(
|
|
1571
|
+
tmp_path: Path,
|
|
1572
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1573
|
+
) -> None:
|
|
1574
|
+
repository, default_branch_tip, topic_branch_tip = _build_default_branch_repository(
|
|
1575
|
+
tmp_path
|
|
1576
|
+
)
|
|
1577
|
+
recorded_arguments_path = tmp_path / "recorded_arguments.txt"
|
|
1578
|
+
recording_gate_script_path = tmp_path / "recording_gate.py"
|
|
1579
|
+
recording_gate_script_path.write_text(
|
|
1580
|
+
"import pathlib, sys\n"
|
|
1581
|
+
f'pathlib.Path(r"{recorded_arguments_path}").write_text('
|
|
1582
|
+
"'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
|
|
1583
|
+
"sys.exit(0)\n",
|
|
1584
|
+
encoding="utf-8",
|
|
1585
|
+
)
|
|
1586
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(recording_gate_script_path))
|
|
1587
|
+
monkeypatch.chdir(repository)
|
|
1588
|
+
monkeypatch.setattr(
|
|
1589
|
+
sys,
|
|
1590
|
+
"stdin",
|
|
1591
|
+
io.StringIO(
|
|
1592
|
+
_push_stdin_line(
|
|
1593
|
+
FEATURE_BRANCH_NAME,
|
|
1594
|
+
topic_branch_tip,
|
|
1595
|
+
FEATURE_BRANCH_NAME,
|
|
1596
|
+
ALL_ZEROS_OBJECT_NAME,
|
|
1597
|
+
)
|
|
1598
|
+
),
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
assert pre_push.main() == 0
|
|
1602
|
+
assert recorded_arguments_path.read_text(encoding="utf-8").splitlines() == [
|
|
1603
|
+
"--base",
|
|
1604
|
+
git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
|
|
1605
|
+
]
|
|
1606
|
+
|
|
1607
|
+
monkeypatch.setattr(
|
|
1608
|
+
sys,
|
|
1609
|
+
"stdin",
|
|
1610
|
+
io.StringIO(
|
|
1611
|
+
_push_stdin_line(
|
|
1612
|
+
FEATURE_BRANCH_NAME,
|
|
1613
|
+
topic_branch_tip,
|
|
1614
|
+
FEATURE_BRANCH_NAME,
|
|
1615
|
+
NON_ZERO_REMOTE_SHA_ONE,
|
|
1616
|
+
)
|
|
1617
|
+
),
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
assert pre_push.main() == 0
|
|
1621
|
+
assert recorded_arguments_path.read_text(encoding="utf-8").splitlines() == [
|
|
1622
|
+
"--base",
|
|
1623
|
+
default_branch_tip,
|
|
1624
|
+
]
|
|
1625
|
+
|
|
1626
|
+
|
|
1570
1627
|
def test_main_skips_a_deletion_line_to_reach_the_branch_update(
|
|
1571
1628
|
tmp_path: Path,
|
|
1572
1629
|
monkeypatch: pytest.MonkeyPatch,
|