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
|
@@ -21,18 +21,34 @@ import sys
|
|
|
21
21
|
import tempfile
|
|
22
22
|
from collections.abc import Generator
|
|
23
23
|
from pathlib import Path
|
|
24
|
+
from types import ModuleType
|
|
24
25
|
|
|
25
26
|
import pytest
|
|
26
27
|
|
|
27
28
|
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "auto_formatter.py")
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
HOOKS_DIRECTORY_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
+
POST_TOOL_USE_DISPATCHER_PATH = os.path.join(
|
|
31
|
+
HOOKS_DIRECTORY_PATH, "validation", "post_tool_use_dispatcher.py"
|
|
30
32
|
)
|
|
33
|
+
PLUGIN_ROOT_PATH = os.path.dirname(HOOKS_DIRECTORY_PATH)
|
|
34
|
+
HOOKS_JSON_PATH = os.path.join(HOOKS_DIRECTORY_PATH, "hooks.json")
|
|
31
35
|
POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT = "validation/post_tool_use_dispatcher.py"
|
|
32
36
|
UNUSED_IMPORT_SOURCE = "import os\n\n\nVALUE = 1\n"
|
|
33
37
|
HOOK_RUN_TIMEOUT_SECONDS = 60
|
|
34
38
|
|
|
35
39
|
|
|
40
|
+
def build_fixture_git_environment() -> dict[str, str]:
|
|
41
|
+
return {
|
|
42
|
+
each_name: each_value
|
|
43
|
+
for each_name, each_value in os.environ.items()
|
|
44
|
+
if not each_name.upper().startswith("GIT_")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_dispatcher_payload(tool_name: str, file_path: Path) -> str:
|
|
49
|
+
return json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}})
|
|
50
|
+
|
|
51
|
+
|
|
36
52
|
def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
|
|
37
53
|
try:
|
|
38
54
|
os.chmod(target_path, stat.S_IWRITE)
|
|
@@ -42,13 +58,8 @@ def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
|
|
|
42
58
|
|
|
43
59
|
|
|
44
60
|
def _force_rmtree(target_path: str) -> None:
|
|
45
|
-
handler_kw = (
|
|
46
|
-
{"onexc": _strip_read_only_and_retry}
|
|
47
|
-
if sys.version_info >= (3, 12)
|
|
48
|
-
else {"onerror": _strip_read_only_and_retry}
|
|
49
|
-
)
|
|
50
61
|
with contextlib.suppress(OSError):
|
|
51
|
-
shutil.rmtree(target_path,
|
|
62
|
+
shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
|
|
52
63
|
|
|
53
64
|
|
|
54
65
|
@functools.lru_cache(maxsize=1)
|
|
@@ -67,20 +78,40 @@ def _cleanup_sandbox_parent_directory() -> Generator[None]:
|
|
|
67
78
|
@pytest.fixture
|
|
68
79
|
def git_repository() -> Generator[Path]:
|
|
69
80
|
repository_path = Path(tempfile.mkdtemp(dir=_get_sandbox_parent_directory()))
|
|
70
|
-
subprocess.run(
|
|
81
|
+
subprocess.run(
|
|
82
|
+
["git", "init"],
|
|
83
|
+
cwd=repository_path,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
check=True,
|
|
86
|
+
env=build_fixture_git_environment(),
|
|
87
|
+
)
|
|
71
88
|
yield repository_path
|
|
72
89
|
_force_rmtree(str(repository_path))
|
|
73
90
|
|
|
74
91
|
|
|
75
92
|
def _run_hook(tool_name: str, file_path: Path) -> subprocess.CompletedProcess[str]:
|
|
76
|
-
hook_input = json.dumps({"tool_name": tool_name, "tool_input": {"file_path": str(file_path)}})
|
|
77
93
|
return subprocess.run(
|
|
78
94
|
[sys.executable, HOOK_SCRIPT_PATH],
|
|
79
|
-
input=
|
|
95
|
+
input=build_dispatcher_payload(tool_name, file_path),
|
|
80
96
|
capture_output=True,
|
|
81
97
|
text=True,
|
|
82
98
|
timeout=HOOK_RUN_TIMEOUT_SECONDS,
|
|
83
99
|
check=False,
|
|
100
|
+
env=build_fixture_git_environment(),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _run_post_tool_use_dispatcher(
|
|
105
|
+
tool_name: str, file_path: Path, environment: dict[str, str]
|
|
106
|
+
) -> subprocess.CompletedProcess[str]:
|
|
107
|
+
return subprocess.run(
|
|
108
|
+
[sys.executable, POST_TOOL_USE_DISPATCHER_PATH, PLUGIN_ROOT_PATH],
|
|
109
|
+
input=build_dispatcher_payload(tool_name, file_path),
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
check=False,
|
|
113
|
+
timeout=HOOK_RUN_TIMEOUT_SECONDS,
|
|
114
|
+
env=environment,
|
|
84
115
|
)
|
|
85
116
|
|
|
86
117
|
|
|
@@ -115,6 +146,7 @@ class TestRuffFixOnNewFiles:
|
|
|
115
146
|
cwd=git_repository,
|
|
116
147
|
capture_output=True,
|
|
117
148
|
check=True,
|
|
149
|
+
env=build_fixture_git_environment(),
|
|
118
150
|
)
|
|
119
151
|
|
|
120
152
|
completed_hook = _run_hook("Write", tracked_file)
|
|
@@ -131,6 +163,7 @@ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> No
|
|
|
131
163
|
cwd=git_repository,
|
|
132
164
|
capture_output=True,
|
|
133
165
|
check=True,
|
|
166
|
+
env=build_fixture_git_environment(),
|
|
134
167
|
)
|
|
135
168
|
|
|
136
169
|
completed_hook = _run_hook("Write", tracked_file)
|
|
@@ -139,7 +172,7 @@ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> No
|
|
|
139
172
|
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
140
173
|
|
|
141
174
|
|
|
142
|
-
def _load_auto_formatter_module() ->
|
|
175
|
+
def _load_auto_formatter_module() -> ModuleType:
|
|
143
176
|
module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
|
|
144
177
|
assert module_spec is not None and module_spec.loader is not None
|
|
145
178
|
auto_formatter_module = importlib.util.module_from_spec(module_spec)
|
|
@@ -147,6 +180,222 @@ def _load_auto_formatter_module() -> object:
|
|
|
147
180
|
return auto_formatter_module
|
|
148
181
|
|
|
149
182
|
|
|
183
|
+
def test_formatter_eligibility_requires_write_tool_and_untracked_source(
|
|
184
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
185
|
+
) -> None:
|
|
186
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
187
|
+
source_file = tmp_path / "new_module.py"
|
|
188
|
+
monkeypatch.setattr(auto_formatter_module, "is_untracked_in_git", lambda _: True)
|
|
189
|
+
|
|
190
|
+
assert auto_formatter_module.is_formatter_eligible("Write", str(source_file))
|
|
191
|
+
assert not auto_formatter_module.is_formatter_eligible("Bash", str(source_file))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_formatter_eligibility_protects_hook_tree(
|
|
195
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
196
|
+
) -> None:
|
|
197
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
198
|
+
hooks_directory = tmp_path / "hooks"
|
|
199
|
+
protected_file = hooks_directory / "generated.py"
|
|
200
|
+
monkeypatch.setattr(
|
|
201
|
+
auto_formatter_module,
|
|
202
|
+
"HOOKS_DIR",
|
|
203
|
+
f"{hooks_directory}{os.sep}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
assert auto_formatter_module.is_protected_path(str(protected_file))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_formatter_eligibility_protects_symlinked_hook_tree(
|
|
210
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
211
|
+
) -> None:
|
|
212
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
213
|
+
hooks_directory = tmp_path / "hooks"
|
|
214
|
+
hooks_directory.mkdir()
|
|
215
|
+
outside_file = tmp_path / "outside.py"
|
|
216
|
+
outside_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
217
|
+
symlinked_file = hooks_directory / "linked.py"
|
|
218
|
+
try:
|
|
219
|
+
symlinked_file.symlink_to(outside_file)
|
|
220
|
+
except (OSError, NotImplementedError):
|
|
221
|
+
return
|
|
222
|
+
monkeypatch.setattr(
|
|
223
|
+
auto_formatter_module,
|
|
224
|
+
"HOOKS_DIR",
|
|
225
|
+
f"{hooks_directory}{os.sep}",
|
|
226
|
+
)
|
|
227
|
+
monkeypatch.setattr(auto_formatter_module, "is_untracked_in_git", lambda _: True)
|
|
228
|
+
|
|
229
|
+
assert auto_formatter_module.is_formatter_eligible("Write", str(symlinked_file)) is False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_formatter_eligibility_protects_symlink_target_inside_hook_tree(
|
|
233
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
234
|
+
) -> None:
|
|
235
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
236
|
+
hooks_directory = tmp_path / "hooks"
|
|
237
|
+
hooks_directory.mkdir()
|
|
238
|
+
protected_file = hooks_directory / "protected.py"
|
|
239
|
+
protected_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
240
|
+
symlinked_file = tmp_path / "linked.py"
|
|
241
|
+
try:
|
|
242
|
+
symlinked_file.symlink_to(protected_file)
|
|
243
|
+
except (OSError, NotImplementedError):
|
|
244
|
+
return
|
|
245
|
+
monkeypatch.setattr(
|
|
246
|
+
auto_formatter_module,
|
|
247
|
+
"HOOKS_DIR",
|
|
248
|
+
f"{hooks_directory}{os.sep}",
|
|
249
|
+
)
|
|
250
|
+
monkeypatch.setattr(auto_formatter_module, "is_untracked_in_git", lambda _: True)
|
|
251
|
+
|
|
252
|
+
assert auto_formatter_module.is_formatter_eligible("Write", str(symlinked_file)) is False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_formatter_diagnostic_stays_on_stderr(
|
|
256
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
257
|
+
) -> None:
|
|
258
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
259
|
+
|
|
260
|
+
def return_formatter_failure(
|
|
261
|
+
command: list[str], **_options: object
|
|
262
|
+
) -> subprocess.CompletedProcess[str]:
|
|
263
|
+
return subprocess.CompletedProcess(
|
|
264
|
+
command,
|
|
265
|
+
3,
|
|
266
|
+
"formatter stdout",
|
|
267
|
+
"formatter stderr",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
monkeypatch.setattr(subprocess, "run", return_formatter_failure)
|
|
271
|
+
auto_formatter_module.run_eligible_formatter("broken.py")
|
|
272
|
+
|
|
273
|
+
captured_output = capsys.readouterr()
|
|
274
|
+
assert "formatter stdout" in captured_output.err
|
|
275
|
+
assert "formatter stderr" in captured_output.err
|
|
276
|
+
assert captured_output.out == ""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_prettier_command_uses_windows_npx_and_resolved_file_path(
|
|
280
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
281
|
+
) -> None:
|
|
282
|
+
auto_formatter_module = _load_auto_formatter_module()
|
|
283
|
+
candidate_file = tmp_path / "linked_module.js"
|
|
284
|
+
captured_commands: list[list[str]] = []
|
|
285
|
+
|
|
286
|
+
def capture_formatter_command(
|
|
287
|
+
command: list[str], _file_path: str, _timeout_seconds: int
|
|
288
|
+
) -> tuple[subprocess.CompletedProcess[str], bool]:
|
|
289
|
+
captured_commands.append(command)
|
|
290
|
+
return subprocess.CompletedProcess(command, 0, "", ""), False
|
|
291
|
+
|
|
292
|
+
monkeypatch.setattr(auto_formatter_module, "_run_command", capture_formatter_command)
|
|
293
|
+
|
|
294
|
+
auto_formatter_module._run_prettier(str(candidate_file))
|
|
295
|
+
|
|
296
|
+
assert captured_commands == [
|
|
297
|
+
[
|
|
298
|
+
auto_formatter_module.NPX_EXECUTABLE,
|
|
299
|
+
"--yes",
|
|
300
|
+
"prettier",
|
|
301
|
+
"--write",
|
|
302
|
+
os.path.realpath(candidate_file),
|
|
303
|
+
]
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_tracked_write_ignores_redirected_git_dir(
|
|
308
|
+
git_repository: Path,
|
|
309
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
310
|
+
tmp_path: Path,
|
|
311
|
+
) -> None:
|
|
312
|
+
tracked_file = git_repository / "redirected_git_dir.py"
|
|
313
|
+
tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
314
|
+
subprocess.run(
|
|
315
|
+
["git", "add", "redirected_git_dir.py"],
|
|
316
|
+
cwd=git_repository,
|
|
317
|
+
capture_output=True,
|
|
318
|
+
check=True,
|
|
319
|
+
env=build_fixture_git_environment(),
|
|
320
|
+
)
|
|
321
|
+
redirected_repository = tmp_path / "redirected_repository"
|
|
322
|
+
redirected_repository.mkdir()
|
|
323
|
+
subprocess.run(
|
|
324
|
+
["git", "init"],
|
|
325
|
+
cwd=redirected_repository,
|
|
326
|
+
capture_output=True,
|
|
327
|
+
check=True,
|
|
328
|
+
env=build_fixture_git_environment(),
|
|
329
|
+
)
|
|
330
|
+
monkeypatch.setenv("GIT_DIR", str(redirected_repository / ".git"))
|
|
331
|
+
|
|
332
|
+
completed_hook = _run_hook("Write", tracked_file)
|
|
333
|
+
|
|
334
|
+
assert completed_hook.returncode == 0
|
|
335
|
+
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_dispatcher_preserves_redirected_git_dir_until_formatter_hook(
|
|
339
|
+
git_repository: Path,
|
|
340
|
+
tmp_path: Path,
|
|
341
|
+
) -> None:
|
|
342
|
+
tracked_file = git_repository / "dispatcher_redirected_git_dir.py"
|
|
343
|
+
tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
344
|
+
subprocess.run(
|
|
345
|
+
["git", "add", "dispatcher_redirected_git_dir.py"],
|
|
346
|
+
cwd=git_repository,
|
|
347
|
+
capture_output=True,
|
|
348
|
+
check=True,
|
|
349
|
+
env=build_fixture_git_environment(),
|
|
350
|
+
)
|
|
351
|
+
redirected_repository = tmp_path / "dispatcher_redirected_repository"
|
|
352
|
+
redirected_repository.mkdir()
|
|
353
|
+
subprocess.run(
|
|
354
|
+
["git", "init"],
|
|
355
|
+
cwd=redirected_repository,
|
|
356
|
+
capture_output=True,
|
|
357
|
+
check=True,
|
|
358
|
+
env=build_fixture_git_environment(),
|
|
359
|
+
)
|
|
360
|
+
dispatcher_environment = os.environ.copy()
|
|
361
|
+
dispatcher_environment["GIT_DIR"] = str(redirected_repository / ".git")
|
|
362
|
+
|
|
363
|
+
completed_dispatcher = _run_post_tool_use_dispatcher(
|
|
364
|
+
"Write", tracked_file, dispatcher_environment
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
assert completed_dispatcher.returncode == 0
|
|
368
|
+
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_dispatcher_run_uses_hook_timeout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
372
|
+
captured_options: dict[str, object] = {}
|
|
373
|
+
|
|
374
|
+
def capture_dispatcher_run(
|
|
375
|
+
command: list[str], **options: object
|
|
376
|
+
) -> subprocess.CompletedProcess[str]:
|
|
377
|
+
captured_options.update(options)
|
|
378
|
+
return subprocess.CompletedProcess(command, 0, "", "")
|
|
379
|
+
|
|
380
|
+
monkeypatch.setattr(subprocess, "run", capture_dispatcher_run)
|
|
381
|
+
|
|
382
|
+
_run_post_tool_use_dispatcher("Write", tmp_path / "module.py", {})
|
|
383
|
+
|
|
384
|
+
assert captured_options["timeout"] == HOOK_RUN_TIMEOUT_SECONDS
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_python_formatting_preserves_crlf_line_endings(git_repository: Path) -> None:
|
|
388
|
+
source_file = git_repository / "crlf_module.py"
|
|
389
|
+
source_file.write_bytes(b"x=1\r\ny = 2\r\n")
|
|
390
|
+
|
|
391
|
+
completed_hook = _run_hook("Write", source_file)
|
|
392
|
+
|
|
393
|
+
assert completed_hook.returncode == 0
|
|
394
|
+
formatted_source = source_file.read_bytes()
|
|
395
|
+
assert b"\r\n" in formatted_source
|
|
396
|
+
assert b"\n" not in formatted_source.replace(b"\r\n", b"")
|
|
397
|
+
|
|
398
|
+
|
|
150
399
|
def _registered_auto_formatter_timeout() -> int:
|
|
151
400
|
with open(HOOKS_JSON_PATH, encoding="utf-8") as hooks_file:
|
|
152
401
|
hooks_configuration = json.load(hooks_file)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Behavior tests for investigation tracker reset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
HOOK_DIRECTORY = Path(__file__).parent
|
|
15
|
+
HOOK_SPEC = importlib.util.spec_from_file_location(
|
|
16
|
+
"investigation_tracker_reset",
|
|
17
|
+
HOOK_DIRECTORY / "investigation_tracker_reset.py",
|
|
18
|
+
)
|
|
19
|
+
assert HOOK_SPEC is not None
|
|
20
|
+
assert HOOK_SPEC.loader is not None
|
|
21
|
+
HOOK_MODULE = importlib.util.module_from_spec(HOOK_SPEC)
|
|
22
|
+
HOOK_SPEC.loader.exec_module(HOOK_MODULE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_main(input_text: str) -> int | None:
|
|
26
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
27
|
+
try:
|
|
28
|
+
HOOK_MODULE.main()
|
|
29
|
+
except SystemExit as system_exit:
|
|
30
|
+
if system_exit.code not in (None, 0):
|
|
31
|
+
raise
|
|
32
|
+
return system_exit.code if isinstance(system_exit.code, int) else None
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("delegation_tool", ["Agent", "Task", "TeamCreate"])
|
|
37
|
+
def test_main_removes_tracker_after_delegation(
|
|
38
|
+
tmp_path: Path,
|
|
39
|
+
delegation_tool: str,
|
|
40
|
+
) -> None:
|
|
41
|
+
tracker_path = tmp_path / "investigation-tracker.json"
|
|
42
|
+
tracker_path.write_text("{}", encoding="utf-8")
|
|
43
|
+
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
44
|
+
|
|
45
|
+
exit_status = _run_main(json.dumps({"tool_name": delegation_tool}))
|
|
46
|
+
|
|
47
|
+
assert exit_status in (None, 0)
|
|
48
|
+
assert not tracker_path.exists()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("unrelated_tool", ["Read", "Bash", "Grep"])
|
|
52
|
+
def test_main_keeps_tracker_for_unrelated_tool(
|
|
53
|
+
tmp_path: Path,
|
|
54
|
+
unrelated_tool: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
tracker_path = tmp_path / "investigation-tracker.json"
|
|
57
|
+
tracker_path.write_text("{}", encoding="utf-8")
|
|
58
|
+
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
59
|
+
|
|
60
|
+
exit_status = _run_main(json.dumps({"tool_name": unrelated_tool}))
|
|
61
|
+
|
|
62
|
+
assert exit_status in (None, 0)
|
|
63
|
+
assert tracker_path.exists()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("invalid_input", ["not json", json.dumps([])])
|
|
67
|
+
def test_main_ignores_invalid_input(
|
|
68
|
+
tmp_path: Path,
|
|
69
|
+
invalid_input: str,
|
|
70
|
+
) -> None:
|
|
71
|
+
tracker_path = tmp_path / "investigation-tracker.json"
|
|
72
|
+
tracker_path.write_text("{}", encoding="utf-8")
|
|
73
|
+
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
74
|
+
|
|
75
|
+
exit_status = _run_main(invalid_input)
|
|
76
|
+
|
|
77
|
+
assert exit_status in (None, 0)
|
|
78
|
+
assert tracker_path.exists()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_main_keeps_running_when_tracker_removal_fails(tmp_path: Path) -> None:
|
|
82
|
+
tracker_path = tmp_path / "investigation-tracker.json"
|
|
83
|
+
tracker_path.write_text("{}", encoding="utf-8")
|
|
84
|
+
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
85
|
+
|
|
86
|
+
with mock.patch.object(HOOK_MODULE.os, "remove", side_effect=OSError("locked")):
|
|
87
|
+
exit_status = _run_main(json.dumps({"tool_name": "Agent"}))
|
|
88
|
+
|
|
89
|
+
assert exit_status in (None, 0)
|
|
90
|
+
assert tracker_path.exists()
|
package/package.json
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Failure Blast Radius
|
|
2
|
+
|
|
3
|
+
**When this applies:** Batch code that processes assets, rows, accounts, messages, or files where one member can fail while the others are fine.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
A check that raises decides two things at once: that a condition requires action, and what stops because of it. Name the second one.
|
|
8
|
+
|
|
9
|
+
An exception type ending in `RunFatal` says the whole run stops. An exception type ending in `ItemBlocked` says this one member stops and the batch carries on. Every raise reached through per-member work uses one or the other.
|
|
10
|
+
|
|
11
|
+
Define `RunFatal` outside the `ItemBlocked` inheritance branch. The per-member boundary routes `ItemBlocked` to parking, while a `RunFatal` escalation passes straight through to the run-level branch.
|
|
12
|
+
|
|
13
|
+
## What ends a run
|
|
14
|
+
|
|
15
|
+
Four failures end a run, and they share one property: continuing compromises delivery integrity.
|
|
16
|
+
|
|
17
|
+
Three of them are declared, and carry a `RunFatal` type:
|
|
18
|
+
|
|
19
|
+
- The source bytes changed under the run.
|
|
20
|
+
- A provenance or digest comparison failed.
|
|
21
|
+
- Authentication is required.
|
|
22
|
+
|
|
23
|
+
The fourth is a runtime crash, such as `TypeError` or `AttributeError`. Its runtime type reaches run-level handling because the per-member boundary recognizes the contract's named types.
|
|
24
|
+
|
|
25
|
+
All remaining failures are member failures. A size mismatch, an optional input error, a path resolution error, or a manifest-field error each stops one member while the batch continues with its other members.
|
|
26
|
+
|
|
27
|
+
## The boundary
|
|
28
|
+
|
|
29
|
+
Put the `try`/`except` inside the loop body, around the per-member work:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
for each_member in all_members:
|
|
33
|
+
try:
|
|
34
|
+
remaster(each_member)
|
|
35
|
+
except AssetRunFatal:
|
|
36
|
+
raise
|
|
37
|
+
except AssetItemBlocked as failure:
|
|
38
|
+
park(each_member, failure)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The re-raise comes first, sending an escalation directly through the boundary.
|
|
42
|
+
|
|
43
|
+
**The boundary recognizes declared types.** A runtime crash inside member work — a `TypeError`, an `AttributeError` — ends the run because its type identifies a code defect. `except Exception` triggers the rule (`CODE_RULES.md` §31).
|
|
44
|
+
|
|
45
|
+
## Repair, park, and the deliverable
|
|
46
|
+
|
|
47
|
+
An agent that hits a member failure keeps working the problem. Three things bound how:
|
|
48
|
+
|
|
49
|
+
- **Repair in place.** The current run preserves every completed member.
|
|
50
|
+
- **Three real attempts, then park.** An attempt is a theory of the cause, acted on. Re-running the same code on the same input counts as one attempt. Three theories cover the obvious cause, the second guess, and the cause revealed by the first two attempts. Judgment selects each theory; the third attempt sets the stopping point. After the third theory fails, park the member with its reason and move to the next. Parked members return after the batch.
|
|
51
|
+
- **The batch always reaches a deliverable.** Complete every member that can complete, produce the packaged artifact, then work the parked list. A run with 34 of 37 members complete and 3 parked records progress and continues to delivery.
|
|
52
|
+
|
|
53
|
+
## Three alike means one cause
|
|
54
|
+
|
|
55
|
+
When three or more members park with the same failure signature — same exception type, same `file:line` — one shared defect affects all three members. Route repair to the shared cause.
|
|
56
|
+
|
|
57
|
+
The run report groups parked members by that signature and names every group of three or more as a suspected shared cause. The raise site provides a reliable grouping key and removes message-text normalization.
|
|
58
|
+
|
|
59
|
+
## Close the run with every outcome
|
|
60
|
+
|
|
61
|
+
Every issue the run hit gets one line in the closing report: what failed, and how it ended — repaired, worked around, or parked. Members that finished after a repair belong in that list beside the parked ones. A workaround patched past mid-run is the likeliest real defect in the batch, because the closing report becomes its durable record.
|
|
62
|
+
|
|
63
|
+
The report then presents each candidate to the owner for a durable-fix decision, names the fixes the run recommends, and waits for the answer. The next run builds the selected fix; the current run completes its deliverable first.
|
|
64
|
+
|
|
65
|
+
## Enforcement
|
|
66
|
+
|
|
67
|
+
`code_rules_blast_radius.py` (PreToolUse on Write and Edit, hosted by `code_rules_enforcer.py`) requires each raised type written directly inside a loop body to end in `RunFatal` or `ItemBlocked`. The lexical check covers raises written directly in loop bodies. Shared helpers carry multiple caller contexts, so their callers classify the boundary.
|
|
68
|
+
|
|
69
|
+
Findings use baseline content for each edit. A raise present on disk remains accepted during the edit; the gate evaluates newly written raises.
|
|
70
|
+
|
|
71
|
+
## Excerpt for repository-instruction sessions
|
|
72
|
+
|
|
73
|
+
Codex reads its repository `AGENTS.md`; this excerpt supplies the standalone failure-handling contract.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Failure handling for this run — from rules/failure-blast-radius.md.
|
|
77
|
+
|
|
78
|
+
Keep solving problems. You own the fix. Each blast radius sets the repair
|
|
79
|
+
scope, and the deliverable remains the run priority.
|
|
80
|
+
|
|
81
|
+
Repair in place and preserve every completed asset in the current run.
|
|
82
|
+
|
|
83
|
+
Three real attempts, then park. An attempt is a theory of the cause, acted
|
|
84
|
+
on. Repeated execution of one theory remains one attempt.
|
|
85
|
+
Three theories cover the obvious cause, the second guess, and the cause
|
|
86
|
+
revealed by the first two attempts. Use your judgment to select each theory;
|
|
87
|
+
the third attempt sets the stopping point. After the third theory fails, park
|
|
88
|
+
the asset with its reason and continue. Parked assets return after the batch.
|
|
89
|
+
|
|
90
|
+
The batch always reaches a deliverable. Finish every asset you can, produce
|
|
91
|
+
the packaged artifact, then work the parked list.
|
|
92
|
+
|
|
93
|
+
When three or more assets fail the same way, one shared defect affects all
|
|
94
|
+
three assets. Route repair to the shared cause.
|
|
95
|
+
|
|
96
|
+
Four things end a run outright: the source bytes changed, a provenance or
|
|
97
|
+
digest mismatch, authentication is required, or the code crashed with a
|
|
98
|
+
runtime failure that requires run-level handling. Work every other failure. The
|
|
99
|
+
named-type boundary defines the accepted exception handling; broad `except`
|
|
100
|
+
handling triggers the rule.
|
|
101
|
+
|
|
102
|
+
When you add a check that raises, name what it stops. End the type in
|
|
103
|
+
RunFatal when the whole run stops, or ItemBlocked when a single asset
|
|
104
|
+
stops. For an asset-level stop, put the handling inside the loop body.
|
|
105
|
+
|
|
106
|
+
Close the run by reporting what broke and what you did about it. Every issue
|
|
107
|
+
gets one line: what failed, and how it ended — repaired, worked around, or
|
|
108
|
+
parked. Include the ones you solved; a workaround you patched past in attempt
|
|
109
|
+
two is the likeliest real defect in the list, because the closing report becomes
|
|
110
|
+
its durable record.
|
|
111
|
+
Present each candidate for the owner's durable-fix decision, state which fixes
|
|
112
|
+
you recommend and why, and wait for the answer. The next run builds the
|
|
113
|
+
selected durable fix after this run completes its deliverable.
|
|
114
|
+
|
|
115
|
+
Report as: N of M complete, K parked, and what you are working now.
|
|
116
|
+
Close with: what broke, how each one ended, and which of them deserve a
|
|
117
|
+
durable fix.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Sibling rules
|
|
121
|
+
|
|
122
|
+
| Rule | Role |
|
|
123
|
+
|---|---|
|
|
124
|
+
| [`code-standards.md`](code-standards.md) | `CODE_RULES.md` §9.7 names the boundary that turns a recorded per-member failure into an explicit outcome |
|
|
125
|
+
| [`confirm-implementation-forks.md`](confirm-implementation-forks.md) | A defect correction creates an implementation fork when it routes around parking; surface and decide that fork |
|
|
126
|
+
| [`long-horizon-autonomy.md`](long-horizon-autonomy.md) | A parked member receives an explicit report entry and follow-up |
|