claude-dev-env 2.19.0 → 2.21.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/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
- 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 +109 -11
- 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/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/docs/CODE_RULES.md +2 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +95 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
- 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 +3 -3
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -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/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/scripts/codex_compat_materializer.py +395 -17
- package/scripts/tests/test_codex_compat_materializer.py +17 -3
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
+
"""Format eligible newly written source files without blocking the write."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
3
7
|
import importlib
|
|
4
8
|
import json
|
|
5
9
|
import os
|
|
6
10
|
import platform
|
|
7
11
|
import subprocess
|
|
8
12
|
import sys
|
|
13
|
+
from collections.abc import Mapping
|
|
9
14
|
from pathlib import Path
|
|
10
15
|
from types import ModuleType
|
|
11
16
|
|
|
@@ -14,6 +19,37 @@ NOTIFICATION_UTILS_DIRECTORY = os.path.join(
|
|
|
14
19
|
)
|
|
15
20
|
sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
|
|
16
21
|
|
|
22
|
+
PYTHON_EXTENSIONS = frozenset({".py"})
|
|
23
|
+
JS_EXTENSIONS = frozenset({".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"})
|
|
24
|
+
JSON_EXTENSIONS = frozenset({".json"})
|
|
25
|
+
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
26
|
+
HOOKS_DIR = os.path.join(PLUGIN_ROOT, "hooks") + os.sep
|
|
27
|
+
PYTHON_FORMAT_TIMEOUT_SECONDS = 12
|
|
28
|
+
JS_FORMAT_TIMEOUT_SECONDS = 30
|
|
29
|
+
GIT_LS_FILES_TIMEOUT_SECONDS = 5
|
|
30
|
+
WRITE_TOOL_NAME = "Write"
|
|
31
|
+
PYTHON_FORMATTER_NAME = "python"
|
|
32
|
+
PRETTIER_FORMATTER_NAME = "prettier"
|
|
33
|
+
NPX_EXECUTABLE = "npx.cmd" if os.name == "nt" else "npx"
|
|
34
|
+
FORMATTER_DIAGNOSTIC_TEMPLATE = "auto-formatter: %s: %s\n"
|
|
35
|
+
FORMATTER_DIAGNOSTIC_SEPARATOR = "\n"
|
|
36
|
+
FORMATTER_TIMEOUT_DIAGNOSTIC_TEMPLATE = "auto-formatter: %s timed out after %d seconds\n"
|
|
37
|
+
PRETTIER_CONFIG_NAMES = frozenset(
|
|
38
|
+
{
|
|
39
|
+
".prettierrc",
|
|
40
|
+
".prettierrc.json",
|
|
41
|
+
".prettierrc.yml",
|
|
42
|
+
".prettierrc.yaml",
|
|
43
|
+
".prettierrc.js",
|
|
44
|
+
".prettierrc.cjs",
|
|
45
|
+
".prettierrc.mjs",
|
|
46
|
+
".prettierrc.toml",
|
|
47
|
+
"prettier.config.js",
|
|
48
|
+
"prettier.config.cjs",
|
|
49
|
+
"prettier.config.mjs",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
17
53
|
|
|
18
54
|
def load_notification_utils() -> ModuleType | None:
|
|
19
55
|
try:
|
|
@@ -41,40 +77,18 @@ def send_format_notification(file_path: str, formatter_name: str) -> None:
|
|
|
41
77
|
pass
|
|
42
78
|
|
|
43
79
|
|
|
44
|
-
PYTHON_EXTENSIONS = {".py"}
|
|
45
|
-
JS_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"}
|
|
46
|
-
JSON_EXTENSIONS = {".json"}
|
|
47
|
-
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
48
|
-
HOOKS_DIR = os.path.join(PLUGIN_ROOT, "hooks") + os.sep
|
|
49
|
-
PYTHON_FORMAT_TIMEOUT_SECONDS = 12
|
|
50
|
-
JS_FORMAT_TIMEOUT_SECONDS = 30
|
|
51
|
-
GIT_LS_FILES_TIMEOUT_SECONDS = 5
|
|
52
|
-
PRETTIER_CONFIG_NAMES = {
|
|
53
|
-
".prettierrc",
|
|
54
|
-
".prettierrc.json",
|
|
55
|
-
".prettierrc.yml",
|
|
56
|
-
".prettierrc.yaml",
|
|
57
|
-
".prettierrc.js",
|
|
58
|
-
".prettierrc.cjs",
|
|
59
|
-
".prettierrc.mjs",
|
|
60
|
-
".prettierrc.toml",
|
|
61
|
-
"prettier.config.js",
|
|
62
|
-
"prettier.config.cjs",
|
|
63
|
-
"prettier.config.mjs",
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
80
|
def has_prettier_config(file_path: str) -> bool:
|
|
68
81
|
each_ancestor = Path(file_path).resolve().parent
|
|
69
82
|
while True:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
if any(
|
|
84
|
+
(each_ancestor / each_config_name).exists()
|
|
85
|
+
for each_config_name in PRETTIER_CONFIG_NAMES
|
|
86
|
+
):
|
|
87
|
+
return True
|
|
88
|
+
parent_directory = each_ancestor.parent
|
|
89
|
+
if parent_directory == each_ancestor:
|
|
90
|
+
return False
|
|
91
|
+
each_ancestor = parent_directory
|
|
78
92
|
|
|
79
93
|
|
|
80
94
|
def budgeted_python_format_seconds() -> int:
|
|
@@ -89,9 +103,7 @@ def budgeted_python_format_seconds() -> int:
|
|
|
89
103
|
bound: when commands are missing or time out the loops can spend more than
|
|
90
104
|
this budget.
|
|
91
105
|
"""
|
|
92
|
-
|
|
93
|
-
format_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
94
|
-
return fix_phase_seconds + format_phase_seconds
|
|
106
|
+
return PYTHON_FORMAT_TIMEOUT_SECONDS * 2
|
|
95
107
|
|
|
96
108
|
|
|
97
109
|
def is_untracked_in_git(file_path: str) -> bool:
|
|
@@ -100,82 +112,202 @@ def is_untracked_in_git(file_path: str) -> bool:
|
|
|
100
112
|
try:
|
|
101
113
|
git_check = subprocess.run(
|
|
102
114
|
["git", "ls-files", "--error-unmatch", file_path],
|
|
103
|
-
check=False,
|
|
115
|
+
check=False,
|
|
116
|
+
capture_output=True,
|
|
104
117
|
text=True,
|
|
105
118
|
cwd=containing_directory,
|
|
106
119
|
timeout=GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
120
|
+
env=_build_git_command_environment(),
|
|
107
121
|
)
|
|
108
122
|
return git_check.returncode != 0
|
|
109
123
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
110
124
|
return False
|
|
111
125
|
|
|
112
126
|
|
|
113
|
-
def
|
|
127
|
+
def _build_git_command_environment() -> dict[str, str]:
|
|
128
|
+
return {
|
|
129
|
+
each_name: each_value
|
|
130
|
+
for each_name, each_value in os.environ.items()
|
|
131
|
+
if not each_name.upper().startswith("GIT_")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_path_under_directory(candidate_path: str, directory_path: str) -> bool:
|
|
136
|
+
try:
|
|
137
|
+
return os.path.commonpath((candidate_path, directory_path)) == directory_path
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_protected_path(file_path: str) -> bool:
|
|
143
|
+
lexical_hooks_directory = os.path.normcase(os.path.abspath(HOOKS_DIR))
|
|
144
|
+
lexical_candidate_path = os.path.normcase(os.path.abspath(file_path))
|
|
145
|
+
resolved_hooks_directory = os.path.normcase(os.path.realpath(HOOKS_DIR))
|
|
146
|
+
resolved_candidate_path = os.path.normcase(os.path.realpath(file_path))
|
|
147
|
+
return _is_path_under_directory(
|
|
148
|
+
lexical_candidate_path, lexical_hooks_directory
|
|
149
|
+
) or _is_path_under_directory(resolved_candidate_path, resolved_hooks_directory)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def formatter_name_for_path(file_path: str) -> str | None:
|
|
153
|
+
suffix = Path(file_path).suffix.lower()
|
|
154
|
+
if suffix in PYTHON_EXTENSIONS:
|
|
155
|
+
return PYTHON_FORMATTER_NAME
|
|
156
|
+
if suffix in JS_EXTENSIONS or suffix in JSON_EXTENSIONS:
|
|
157
|
+
if has_prettier_config(file_path):
|
|
158
|
+
return PRETTIER_FORMATTER_NAME
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_formatter_eligible(tool_name: str, file_path: str) -> bool:
|
|
163
|
+
if tool_name != WRITE_TOOL_NAME or not file_path:
|
|
164
|
+
return False
|
|
165
|
+
if is_protected_path(file_path) or not is_untracked_in_git(file_path):
|
|
166
|
+
return False
|
|
167
|
+
return formatter_name_for_path(file_path) is not None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _write_command_diagnostic(file_path: str, command: list[str], diagnostic_text: str) -> None:
|
|
171
|
+
normalized_diagnostic = diagnostic_text.strip()
|
|
172
|
+
if not normalized_diagnostic:
|
|
173
|
+
return
|
|
174
|
+
command_text = " ".join(command)
|
|
175
|
+
sys.stderr.write(
|
|
176
|
+
FORMATTER_DIAGNOSTIC_TEMPLATE % (file_path, f"{command_text}: {normalized_diagnostic}")
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _combine_command_diagnostics(
|
|
181
|
+
completed_process: subprocess.CompletedProcess[str],
|
|
182
|
+
) -> str:
|
|
183
|
+
all_diagnostics = [
|
|
184
|
+
each_stream.strip()
|
|
185
|
+
for each_stream in (completed_process.stdout, completed_process.stderr)
|
|
186
|
+
if each_stream
|
|
187
|
+
]
|
|
188
|
+
return FORMATTER_DIAGNOSTIC_SEPARATOR.join(all_diagnostics)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_command(
|
|
192
|
+
command: list[str], file_path: str, timeout_seconds: int
|
|
193
|
+
) -> tuple[subprocess.CompletedProcess[str] | None, bool]:
|
|
114
194
|
try:
|
|
115
|
-
|
|
195
|
+
completed_process = subprocess.run(
|
|
196
|
+
command,
|
|
197
|
+
check=False,
|
|
198
|
+
capture_output=True,
|
|
199
|
+
text=True,
|
|
200
|
+
timeout=timeout_seconds,
|
|
201
|
+
)
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return None, False
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
sys.stderr.write(FORMATTER_TIMEOUT_DIAGNOSTIC_TEMPLATE % (file_path, timeout_seconds))
|
|
206
|
+
return None, True
|
|
207
|
+
|
|
208
|
+
if completed_process.returncode != 0:
|
|
209
|
+
_write_command_diagnostic(
|
|
210
|
+
file_path,
|
|
211
|
+
command,
|
|
212
|
+
_combine_command_diagnostics(completed_process),
|
|
213
|
+
)
|
|
214
|
+
return completed_process, False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _run_python_fix_commands(file_path: str) -> None:
|
|
218
|
+
all_fix_commands = [
|
|
219
|
+
["ruff", "check", "--fix", file_path],
|
|
220
|
+
[sys.executable, "-m", "ruff", "check", "--fix", file_path],
|
|
221
|
+
]
|
|
222
|
+
for each_command in all_fix_commands:
|
|
223
|
+
completed_process, did_timeout = _run_command(
|
|
224
|
+
each_command, file_path, PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
225
|
+
)
|
|
226
|
+
if did_timeout or completed_process is not None:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _run_python_format_commands(file_path: str) -> None:
|
|
231
|
+
all_format_commands = [
|
|
232
|
+
["ruff", "format", file_path],
|
|
233
|
+
[sys.executable, "-m", "ruff", "format", file_path],
|
|
234
|
+
["black", file_path],
|
|
235
|
+
[sys.executable, "-m", "black", file_path],
|
|
236
|
+
]
|
|
237
|
+
for each_command in all_format_commands:
|
|
238
|
+
completed_process, did_timeout = _run_command(
|
|
239
|
+
each_command, file_path, PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
240
|
+
)
|
|
241
|
+
if did_timeout:
|
|
242
|
+
return
|
|
243
|
+
if completed_process is not None and completed_process.returncode == 0:
|
|
244
|
+
send_format_notification(file_path, formatter_name_for_command(each_command))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def formatter_name_for_command(command: list[str]) -> str:
|
|
249
|
+
if command[0] == sys.executable:
|
|
250
|
+
return command[2]
|
|
251
|
+
return command[0]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _run_prettier(file_path: str) -> None:
|
|
255
|
+
prettier_command = [
|
|
256
|
+
NPX_EXECUTABLE,
|
|
257
|
+
"--yes",
|
|
258
|
+
"prettier",
|
|
259
|
+
"--write",
|
|
260
|
+
os.path.realpath(file_path),
|
|
261
|
+
]
|
|
262
|
+
completed_process, _did_timeout = _run_command(
|
|
263
|
+
prettier_command, file_path, JS_FORMAT_TIMEOUT_SECONDS
|
|
264
|
+
)
|
|
265
|
+
if completed_process is not None and completed_process.returncode == 0:
|
|
266
|
+
send_format_notification(file_path, PRETTIER_FORMATTER_NAME)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run_eligible_formatter(file_path: str) -> None:
|
|
270
|
+
formatter_name = formatter_name_for_path(file_path)
|
|
271
|
+
if formatter_name == PYTHON_FORMATTER_NAME:
|
|
272
|
+
_run_python_fix_commands(file_path)
|
|
273
|
+
_run_python_format_commands(file_path)
|
|
274
|
+
return
|
|
275
|
+
if formatter_name == PRETTIER_FORMATTER_NAME:
|
|
276
|
+
_run_prettier(file_path)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _read_hook_input() -> dict[str, object] | None:
|
|
280
|
+
try:
|
|
281
|
+
parsed_input = json.load(sys.stdin)
|
|
116
282
|
except (json.JSONDecodeError, ValueError):
|
|
117
|
-
|
|
283
|
+
return None
|
|
284
|
+
if not isinstance(parsed_input, dict):
|
|
285
|
+
return None
|
|
286
|
+
return parsed_input
|
|
118
287
|
|
|
119
|
-
tool_name = hook_input.get("tool_name", "")
|
|
120
|
-
file_path = hook_input.get("tool_input", {}).get("file_path", "")
|
|
121
|
-
if not file_path:
|
|
122
|
-
sys.exit(0)
|
|
123
288
|
|
|
124
|
-
|
|
125
|
-
|
|
289
|
+
def _read_string_field(field_source: Mapping[str, object], field_name: str) -> str:
|
|
290
|
+
field_value = field_source.get(field_name)
|
|
291
|
+
return field_value if isinstance(field_value, str) else ""
|
|
126
292
|
|
|
127
|
-
if tool_name == "Write" and not is_untracked_in_git(file_path):
|
|
128
|
-
sys.exit(0)
|
|
129
293
|
|
|
130
|
-
|
|
131
|
-
|
|
294
|
+
def _read_hook_file_path(hook_input: Mapping[str, object]) -> str:
|
|
295
|
+
tool_input = hook_input.get("tool_input")
|
|
296
|
+
if not isinstance(tool_input, dict):
|
|
297
|
+
return ""
|
|
298
|
+
return _read_string_field(tool_input, "file_path")
|
|
132
299
|
|
|
133
|
-
suffix = Path(file_path).suffix.lower()
|
|
134
300
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
except subprocess.TimeoutExpired:
|
|
146
|
-
break
|
|
147
|
-
for each_formatter_command in [
|
|
148
|
-
["ruff", "format", file_path],
|
|
149
|
-
[sys.executable, "-m", "ruff", "format", file_path],
|
|
150
|
-
["black", file_path],
|
|
151
|
-
[sys.executable, "-m", "black", file_path],
|
|
152
|
-
]:
|
|
153
|
-
try:
|
|
154
|
-
format_run = subprocess.run(each_formatter_command, check=False, capture_output=True, text=True, timeout=PYTHON_FORMAT_TIMEOUT_SECONDS)
|
|
155
|
-
if format_run.returncode == 0:
|
|
156
|
-
formatter_name = each_formatter_command[0] if each_formatter_command[0] != sys.executable else each_formatter_command[2]
|
|
157
|
-
send_format_notification(file_path, formatter_name)
|
|
158
|
-
break
|
|
159
|
-
except FileNotFoundError:
|
|
160
|
-
continue
|
|
161
|
-
except subprocess.TimeoutExpired:
|
|
162
|
-
break
|
|
163
|
-
elif suffix in JS_EXTENSIONS or suffix in JSON_EXTENSIONS:
|
|
164
|
-
if not has_prettier_config(file_path):
|
|
165
|
-
sys.exit(0)
|
|
166
|
-
try:
|
|
167
|
-
prettier_run = subprocess.run(
|
|
168
|
-
["npx", "--yes", "prettier", "--write", file_path],
|
|
169
|
-
check=False, capture_output=True,
|
|
170
|
-
text=True,
|
|
171
|
-
timeout=JS_FORMAT_TIMEOUT_SECONDS,
|
|
172
|
-
)
|
|
173
|
-
if prettier_run.returncode == 0:
|
|
174
|
-
send_format_notification(file_path, "prettier")
|
|
175
|
-
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
176
|
-
pass
|
|
177
|
-
|
|
178
|
-
sys.exit(0)
|
|
301
|
+
def main() -> None:
|
|
302
|
+
hook_input = _read_hook_input()
|
|
303
|
+
if hook_input is None:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
tool_name = _read_string_field(hook_input, "tool_name")
|
|
307
|
+
file_path = _read_hook_file_path(hook_input)
|
|
308
|
+
if not is_formatter_eligible(tool_name, file_path):
|
|
309
|
+
return
|
|
310
|
+
run_eligible_formatter(file_path)
|
|
179
311
|
|
|
180
312
|
|
|
181
313
|
if __name__ == "__main__":
|