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.
Files changed (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /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(hook_filename: str, stdin_text: str) -> subprocess.CompletedProcess:
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 subprocess.run(
54
- [sys.executable, str(hook_script_path)],
55
- input=stdin_text,
56
- capture_output=True,
57
- text=True,
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(hook_filename: str, stdin_text: str) -> None:
73
- completed = _run_hook_script(hook_filename, stdin_text)
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 the CODE_RULES gate (`precommit_code_rules_gate.py`) over staged changes; exits 1 when any staged file has a blocking violation |
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"