claude-dev-env 2.18.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +105 -75
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +6 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +9 -5
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/json_file_reader.py +22 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/pending_sidecars.py +33 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Deny a Write or
|
|
2
|
+
"""Deny a Write, Edit, or MultiEdit whose target filename names a secret or a lock file.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
@@ -35,10 +35,12 @@ import sys
|
|
|
35
35
|
import _path_setup # noqa: F401
|
|
36
36
|
|
|
37
37
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
38
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
39
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
40
|
+
)
|
|
38
41
|
from hooks_constants.sensitive_file_protector_constants import (
|
|
39
42
|
ALL_SENSITIVE_PATTERNS,
|
|
40
43
|
ALL_TEMPLATE_SUFFIXES,
|
|
41
|
-
ALL_WRITE_EDIT_TOOLS,
|
|
42
44
|
DENY_DECISION,
|
|
43
45
|
DENY_REASON_TEMPLATE,
|
|
44
46
|
HOOK_EVENT_NAME,
|
|
@@ -57,7 +59,7 @@ def is_template_filename(filename: str) -> bool:
|
|
|
57
59
|
.env.local -> False
|
|
58
60
|
|
|
59
61
|
Args:
|
|
60
|
-
filename: The basename of the file a Write or
|
|
62
|
+
filename: The basename of the file a Write, Edit, or MultiEdit targets.
|
|
61
63
|
|
|
62
64
|
Returns:
|
|
63
65
|
True when the basename's final suffix is a template suffix.
|
|
@@ -69,7 +71,7 @@ def is_sensitive_file(file_path: str) -> str | None:
|
|
|
69
71
|
"""Return the sensitive pattern a path's basename matches, or None.
|
|
70
72
|
|
|
71
73
|
Args:
|
|
72
|
-
file_path: The path the Write or
|
|
74
|
+
file_path: The path the Write, Edit, or MultiEdit targets.
|
|
73
75
|
|
|
74
76
|
Returns:
|
|
75
77
|
The matched pattern, or None when the basename names a template or
|
|
@@ -106,7 +108,7 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
|
|
|
106
108
|
"""Record the block in the hook-blocks log and emit the deny decision.
|
|
107
109
|
|
|
108
110
|
Args:
|
|
109
|
-
file_path: The path the Write or
|
|
111
|
+
file_path: The path the Write, Edit, or MultiEdit targets.
|
|
110
112
|
matched_pattern: The sensitive pattern the basename matched.
|
|
111
113
|
"""
|
|
112
114
|
deny_reason = DENY_REASON_TEMPLATE.format(
|
|
@@ -132,7 +134,7 @@ def main() -> None:
|
|
|
132
134
|
tool_name = hook_input.get("tool_name", "")
|
|
133
135
|
tool_input = hook_input.get("tool_input", {})
|
|
134
136
|
|
|
135
|
-
if tool_name not in
|
|
137
|
+
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
136
138
|
sys.exit(0)
|
|
137
139
|
|
|
138
140
|
file_path = tool_input.get("file_path", "")
|
|
@@ -18,6 +18,7 @@ blocks on infrastructure trouble.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import json
|
|
21
|
+
import os
|
|
21
22
|
import shlex
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
@@ -601,6 +602,35 @@ def _build_denial(all_offending_paths: list[str]) -> dict:
|
|
|
601
602
|
}
|
|
602
603
|
|
|
603
604
|
|
|
605
|
+
def _resolve_hook_working_directory(
|
|
606
|
+
bash_command: str,
|
|
607
|
+
all_hook_payload: dict[str, object],
|
|
608
|
+
) -> str | None:
|
|
609
|
+
"""Resolve the commit directory from the command or its event payload.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
bash_command: Bash command whose explicit directory takes precedence.
|
|
613
|
+
all_hook_payload: PreToolUse payload carrying the event ``cwd``.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
The validated commit directory, or None for the hook process directory.
|
|
617
|
+
"""
|
|
618
|
+
command_directory = extract_git_working_directory(bash_command)
|
|
619
|
+
event_directory = all_hook_payload.get("cwd")
|
|
620
|
+
if not isinstance(event_directory, str) or not event_directory:
|
|
621
|
+
return resolve_directory(command_directory)
|
|
622
|
+
validated_event_directory = resolve_directory(event_directory)
|
|
623
|
+
if command_directory is None:
|
|
624
|
+
return validated_event_directory
|
|
625
|
+
expanded_command_directory = os.path.expanduser(command_directory)
|
|
626
|
+
command_path = Path(expanded_command_directory)
|
|
627
|
+
if command_path.is_absolute():
|
|
628
|
+
return resolve_directory(str(command_path))
|
|
629
|
+
if validated_event_directory is None:
|
|
630
|
+
return None
|
|
631
|
+
return resolve_directory(str(Path(validated_event_directory) / command_path))
|
|
632
|
+
|
|
633
|
+
|
|
604
634
|
def main() -> None:
|
|
605
635
|
"""Deny a git commit that would drop a tracked file that was edited yet left unstaged."""
|
|
606
636
|
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
@@ -620,7 +650,7 @@ def main() -> None:
|
|
|
620
650
|
session_edited_keys = _session_edited_keys(session_id)
|
|
621
651
|
if not session_edited_keys:
|
|
622
652
|
sys.exit(0)
|
|
623
|
-
working_directory =
|
|
653
|
+
working_directory = _resolve_hook_working_directory(bash_command, hook_payload)
|
|
624
654
|
repository_root = resolve_repository_root(working_directory)
|
|
625
655
|
if repository_root is None:
|
|
626
656
|
sys.exit(0)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Production-path tests for direct commit branch protection."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_git(from_directory: Path, *arguments: str) -> None:
|
|
11
|
+
subprocess.run(
|
|
12
|
+
["git", *arguments],
|
|
13
|
+
cwd=from_directory,
|
|
14
|
+
check=True,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _create_repository(from_directory: Path, branch_name: str) -> Path:
|
|
21
|
+
repository = from_directory / branch_name
|
|
22
|
+
repository.mkdir()
|
|
23
|
+
_run_git(repository, "init", "--initial-branch", "main")
|
|
24
|
+
hook_directory = repository / ".git" / "test-hooks"
|
|
25
|
+
hook_directory.mkdir()
|
|
26
|
+
_run_git(repository, "config", "core.hooksPath", str(hook_directory))
|
|
27
|
+
_run_git(repository, "config", "user.email", "test@example.com")
|
|
28
|
+
_run_git(repository, "config", "user.name", "Test User")
|
|
29
|
+
(repository / "tracked.txt").write_text("tracked\n", encoding="utf-8")
|
|
30
|
+
_run_git(repository, "add", "tracked.txt")
|
|
31
|
+
_run_git(repository, "-c", "commit.gpgsign=false", "commit", "-m", "initial")
|
|
32
|
+
if branch_name != "main":
|
|
33
|
+
_run_git(repository, "switch", "-c", branch_name)
|
|
34
|
+
return repository
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_commit_gate(
|
|
38
|
+
from_directory: Path,
|
|
39
|
+
process_directory: Path,
|
|
40
|
+
command: str = "git commit -m test",
|
|
41
|
+
include_event_cwd: bool = True,
|
|
42
|
+
) -> subprocess.CompletedProcess[str]:
|
|
43
|
+
hook_script = Path(__file__).with_name("block_main_commit.py")
|
|
44
|
+
home_directory = from_directory.parent / "test-home"
|
|
45
|
+
home_directory.mkdir(exist_ok=True)
|
|
46
|
+
environment = os.environ.copy()
|
|
47
|
+
environment.update({"HOME": str(home_directory), "USERPROFILE": str(home_directory)})
|
|
48
|
+
hook_payload: dict[str, object] = {
|
|
49
|
+
"tool_name": "Bash",
|
|
50
|
+
"tool_input": {"command": command},
|
|
51
|
+
}
|
|
52
|
+
if include_event_cwd:
|
|
53
|
+
hook_payload["cwd"] = str(from_directory)
|
|
54
|
+
return subprocess.run(
|
|
55
|
+
[sys.executable, str(hook_script)],
|
|
56
|
+
cwd=process_directory,
|
|
57
|
+
input=json.dumps(hook_payload),
|
|
58
|
+
check=False,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
env=environment,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_blocks_commit_on_protected_branch_from_hook_event_cwd(tmp_path: Path) -> None:
|
|
66
|
+
repository = _create_repository(tmp_path, "main")
|
|
67
|
+
process_repository = _create_repository(tmp_path, "agent-owned-change")
|
|
68
|
+
|
|
69
|
+
completed_process = _run_commit_gate(repository, process_repository)
|
|
70
|
+
|
|
71
|
+
assert completed_process.returncode == 0
|
|
72
|
+
hook_response = json.loads(completed_process.stdout)
|
|
73
|
+
assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
74
|
+
assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_allows_commit_on_owned_branch_from_hook_event_cwd(tmp_path: Path) -> None:
|
|
78
|
+
repository = _create_repository(tmp_path, "agent-owned-change")
|
|
79
|
+
process_repository = _create_repository(tmp_path, "main")
|
|
80
|
+
|
|
81
|
+
completed_process = _run_commit_gate(repository, process_repository)
|
|
82
|
+
|
|
83
|
+
assert completed_process.returncode == 0
|
|
84
|
+
assert completed_process.stdout == ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_resolves_relative_git_c_from_hook_event_cwd(tmp_path: Path) -> None:
|
|
88
|
+
repository = _create_repository(tmp_path, "main")
|
|
89
|
+
process_repository = _create_repository(tmp_path, "agent-owned-change")
|
|
90
|
+
|
|
91
|
+
completed_process = _run_commit_gate(
|
|
92
|
+
tmp_path,
|
|
93
|
+
process_repository,
|
|
94
|
+
command="git -C main commit -m test",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert completed_process.returncode == 0
|
|
98
|
+
hook_response = json.loads(completed_process.stdout)
|
|
99
|
+
assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
100
|
+
assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_preserves_dispatcher_ownership_without_event_cwd(tmp_path: Path) -> None:
|
|
104
|
+
repository = _create_repository(tmp_path, "main")
|
|
105
|
+
process_repository = _create_repository(tmp_path, "agent-owned-change")
|
|
106
|
+
|
|
107
|
+
completed_process = _run_commit_gate(
|
|
108
|
+
tmp_path,
|
|
109
|
+
process_repository,
|
|
110
|
+
command=f'git -C "{repository}" commit -m test',
|
|
111
|
+
include_event_cwd=False,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
assert completed_process.returncode == 0
|
|
115
|
+
assert completed_process.stdout == ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_matches_case_insensitive_shell_git_command(tmp_path: Path) -> None:
|
|
119
|
+
repository = _create_repository(tmp_path, "main")
|
|
120
|
+
process_repository = _create_repository(tmp_path, "agent-owned-change")
|
|
121
|
+
|
|
122
|
+
completed_process = _run_commit_gate(
|
|
123
|
+
tmp_path,
|
|
124
|
+
process_repository,
|
|
125
|
+
command="CD main && GIT COMMIT -m test",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert completed_process.returncode == 0
|
|
129
|
+
hook_response = json.loads(completed_process.stdout)
|
|
130
|
+
assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
131
|
+
assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_ignores_unrelated_command_containing_git_commit(tmp_path: Path) -> None:
|
|
135
|
+
repository = _create_repository(tmp_path, "main")
|
|
136
|
+
process_repository = _create_repository(tmp_path, "agent-owned-change")
|
|
137
|
+
|
|
138
|
+
completed_process = _run_commit_gate(
|
|
139
|
+
repository,
|
|
140
|
+
process_repository,
|
|
141
|
+
command="echo git commit",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
assert completed_process.returncode == 0
|
|
145
|
+
assert completed_process.stdout == ""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for claude_md_orphan_file_blocker hook."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import importlib
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
@@ -9,7 +10,57 @@ from pathlib import Path
|
|
|
9
10
|
|
|
10
11
|
import pytest
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
_test_file_path = Path(__file__).resolve()
|
|
14
|
+
_blocking_directory = str(_test_file_path.parent)
|
|
15
|
+
_hooks_directory = str(_test_file_path.parent.parent)
|
|
16
|
+
_blocking_directory_key = os.path.normcase(os.path.abspath(_blocking_directory))
|
|
17
|
+
_hooks_directory_key = os.path.normcase(os.path.abspath(_hooks_directory))
|
|
18
|
+
_hooks_directory_origin_key = _hooks_directory_key
|
|
19
|
+
sys.path[:] = [_blocking_directory, _hooks_directory] + [
|
|
20
|
+
each_path
|
|
21
|
+
for each_path in sys.path
|
|
22
|
+
if not isinstance(each_path, str)
|
|
23
|
+
or os.path.normcase(os.path.abspath(each_path))
|
|
24
|
+
not in (_blocking_directory_key, _hooks_directory_key)
|
|
25
|
+
]
|
|
26
|
+
importlib.invalidate_caches()
|
|
27
|
+
|
|
28
|
+
_all_cached_module_prefixes = (
|
|
29
|
+
"claude_md_orphan_file_blocker",
|
|
30
|
+
"claude_md_orphan_file_blocker_parts",
|
|
31
|
+
"inventory_intent_records",
|
|
32
|
+
"hooks_constants",
|
|
33
|
+
"code_rules_annotations_length",
|
|
34
|
+
"code_rules_boolean_mustcheck",
|
|
35
|
+
"code_rules_naming_collection",
|
|
36
|
+
"code_rules_path_utils",
|
|
37
|
+
"code_rules_shared",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
for each_module_name, each_module in tuple(sys.modules.items()):
|
|
42
|
+
if not any(
|
|
43
|
+
each_module_name == each_prefix or each_module_name.startswith(f"{each_prefix}.")
|
|
44
|
+
for each_prefix in _all_cached_module_prefixes
|
|
45
|
+
):
|
|
46
|
+
continue
|
|
47
|
+
module_file = getattr(each_module, "__file__", None)
|
|
48
|
+
is_cached_module_in_this_checkout = False
|
|
49
|
+
if isinstance(module_file, str):
|
|
50
|
+
module_file_key = os.path.normcase(str(Path(module_file).resolve()))
|
|
51
|
+
try:
|
|
52
|
+
is_cached_module_in_this_checkout = (
|
|
53
|
+
os.path.commonpath((_hooks_directory_origin_key, module_file_key))
|
|
54
|
+
== _hooks_directory_origin_key
|
|
55
|
+
)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
if is_cached_module_in_this_checkout:
|
|
59
|
+
continue
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"test_claude_md_orphan_file_blocker: cached module outside this checkout: "
|
|
62
|
+
f"{each_module_name} ({module_file!r})"
|
|
63
|
+
)
|
|
13
64
|
|
|
14
65
|
import claude_md_orphan_file_blocker as blocker_module
|
|
15
66
|
from claude_md_orphan_file_blocker import (
|
|
@@ -82,6 +133,125 @@ def _isolated_claude_md_path(tmp_path: Path) -> Path:
|
|
|
82
133
|
return isolated_directory / "CLAUDE.md"
|
|
83
134
|
|
|
84
135
|
|
|
136
|
+
def _run_isolated_test_module_import(
|
|
137
|
+
driver_setup: str, driver_assertions: str
|
|
138
|
+
) -> subprocess.CompletedProcess:
|
|
139
|
+
"""Import this test module in an isolated interpreter with caller-supplied state."""
|
|
140
|
+
test_module_path = _test_file_path
|
|
141
|
+
driver = (
|
|
142
|
+
"import importlib.util\n"
|
|
143
|
+
"import os\n"
|
|
144
|
+
"import site\n"
|
|
145
|
+
"import sys\n"
|
|
146
|
+
"from pathlib import Path\n"
|
|
147
|
+
f"test_module_path = Path({str(test_module_path)!r})\n"
|
|
148
|
+
"sys.path.append(site.getusersitepackages())\n"
|
|
149
|
+
f"{driver_setup}"
|
|
150
|
+
"spec = importlib.util.spec_from_file_location('bootstrap_target_test', test_module_path)\n"
|
|
151
|
+
"module = importlib.util.module_from_spec(spec)\n"
|
|
152
|
+
"spec.loader.exec_module(module)\n"
|
|
153
|
+
f"{driver_assertions}"
|
|
154
|
+
)
|
|
155
|
+
return subprocess.run(
|
|
156
|
+
[sys.executable, "-I", "-c", driver],
|
|
157
|
+
check=False,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_import_bootstrap_prioritizes_this_checkout(tmp_path: Path) -> None:
|
|
164
|
+
foreign_hooks_directory = tmp_path / "foreign_hooks"
|
|
165
|
+
foreign_blocking_directory = foreign_hooks_directory / "blocking"
|
|
166
|
+
foreign_constants_directory = foreign_hooks_directory / "hooks_constants"
|
|
167
|
+
foreign_blocking_directory.mkdir(parents=True)
|
|
168
|
+
foreign_constants_directory.mkdir()
|
|
169
|
+
(foreign_blocking_directory / "claude_md_orphan_file_blocker.py").write_text(
|
|
170
|
+
"raise RuntimeError('foreign blocker loaded')\n", encoding="utf-8"
|
|
171
|
+
)
|
|
172
|
+
(foreign_constants_directory / "__init__.py").write_text(
|
|
173
|
+
"raise RuntimeError('foreign constants loaded')\n", encoding="utf-8"
|
|
174
|
+
)
|
|
175
|
+
target_blocking_directory = Path(__file__).resolve().parent
|
|
176
|
+
target_hooks_directory = target_blocking_directory.parent
|
|
177
|
+
driver_setup = (
|
|
178
|
+
f"foreign_blocking_directory = Path({str(foreign_blocking_directory)!r})\n"
|
|
179
|
+
f"foreign_hooks_directory = Path({str(foreign_hooks_directory)!r})\n"
|
|
180
|
+
f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
|
|
181
|
+
f"target_hooks_directory = Path({str(target_hooks_directory)!r})\n"
|
|
182
|
+
"sys.path[:0] = [\n"
|
|
183
|
+
" str(foreign_blocking_directory), str(foreign_hooks_directory),\n"
|
|
184
|
+
" str(target_blocking_directory), str(target_hooks_directory),\n"
|
|
185
|
+
"]\n"
|
|
186
|
+
)
|
|
187
|
+
driver_assertions = (
|
|
188
|
+
"import claude_md_orphan_file_blocker\n"
|
|
189
|
+
"import hooks_constants\n"
|
|
190
|
+
"assert Path(claude_md_orphan_file_blocker.__file__).resolve().parent "
|
|
191
|
+
"== target_blocking_directory\n"
|
|
192
|
+
"assert Path(hooks_constants.__file__).resolve().parent.parent "
|
|
193
|
+
"== target_hooks_directory\n"
|
|
194
|
+
"all_uncovered_module_names = [\n"
|
|
195
|
+
" each_module_name\n"
|
|
196
|
+
" for each_module_name, each_loaded_module in sys.modules.items()\n"
|
|
197
|
+
" if each_loaded_module is not module\n"
|
|
198
|
+
" and isinstance(getattr(each_loaded_module, '__file__', None), str)\n"
|
|
199
|
+
" and Path(each_loaded_module.__file__).resolve().is_relative_to(\n"
|
|
200
|
+
" target_hooks_directory\n"
|
|
201
|
+
" )\n"
|
|
202
|
+
" and not any(\n"
|
|
203
|
+
" each_module_name == each_prefix\n"
|
|
204
|
+
" or each_module_name.startswith(f'{each_prefix}.')\n"
|
|
205
|
+
" for each_prefix in module._all_cached_module_prefixes\n"
|
|
206
|
+
" )\n"
|
|
207
|
+
"]\n"
|
|
208
|
+
"assert not all_uncovered_module_names, all_uncovered_module_names\n"
|
|
209
|
+
)
|
|
210
|
+
completed = _run_isolated_test_module_import(driver_setup, driver_assertions)
|
|
211
|
+
assert completed.returncode == 0, completed.stderr
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_import_bootstrap_preserves_non_string_path_entries() -> None:
|
|
215
|
+
completed = _run_isolated_test_module_import(
|
|
216
|
+
"sys.path.insert(0, None)\n", "assert None in sys.path\n"
|
|
217
|
+
)
|
|
218
|
+
assert completed.returncode == 0, completed.stderr
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_import_bootstrap_invalidates_stale_importer_cache() -> None:
|
|
222
|
+
target_blocking_directory = Path(__file__).resolve().parent
|
|
223
|
+
driver_setup = (
|
|
224
|
+
f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
|
|
225
|
+
"sys.path_importer_cache[str(target_blocking_directory)] = None\n"
|
|
226
|
+
)
|
|
227
|
+
completed = _run_isolated_test_module_import(driver_setup, "")
|
|
228
|
+
assert completed.returncode == 0, completed.stderr
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.parametrize(
|
|
232
|
+
"cached_module_name",
|
|
233
|
+
(
|
|
234
|
+
"hooks_constants",
|
|
235
|
+
"claude_md_orphan_file_blocker",
|
|
236
|
+
"code_rules_path_utils",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
def test_import_bootstrap_rejects_foreign_cached_modules(
|
|
240
|
+
tmp_path: Path, cached_module_name: str
|
|
241
|
+
) -> None:
|
|
242
|
+
foreign_module_path = tmp_path / "foreign_hooks" / f"{cached_module_name}.py"
|
|
243
|
+
driver_setup = (
|
|
244
|
+
"import types\n"
|
|
245
|
+
f"cached_module_name = {cached_module_name!r}\n"
|
|
246
|
+
"cached_module = types.ModuleType(cached_module_name)\n"
|
|
247
|
+
f"cached_module.__file__ = {str(foreign_module_path)!r}\n"
|
|
248
|
+
"sys.modules[cached_module_name] = cached_module\n"
|
|
249
|
+
)
|
|
250
|
+
completed = _run_isolated_test_module_import(driver_setup, "")
|
|
251
|
+
assert completed.returncode != 0
|
|
252
|
+
assert f"cached module outside this checkout: {cached_module_name}" in completed.stderr
|
|
253
|
+
|
|
254
|
+
|
|
85
255
|
def test_blocks_write_naming_absent_python_file(tmp_path: Path):
|
|
86
256
|
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
87
257
|
result = _run_hook(
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for the blast-radius declaration check."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
|
|
12
|
+
from code_rules_blast_radius import check_blast_radius_declared # noqa: E402
|
|
13
|
+
|
|
14
|
+
PRODUCTION_PATH = "pipeline/asset_run.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_should_report_a_loop_raise_with_pending_blast_radius_declaration() -> None:
|
|
18
|
+
"""A loop-body raise gets an advisory requesting its blast-radius declaration."""
|
|
19
|
+
content = (
|
|
20
|
+
"def run(all_members):\n"
|
|
21
|
+
" for each_member in all_members:\n"
|
|
22
|
+
" if each_member.requires_preparation:\n"
|
|
23
|
+
" raise AssetError('member requires preparation')\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
all_issues = check_blast_radius_declared(content, PRODUCTION_PATH)
|
|
27
|
+
|
|
28
|
+
assert len(all_issues) == 1
|
|
29
|
+
assert "Line 4" in all_issues[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_should_accept_a_run_fatal_raise_inside_a_loop() -> None:
|
|
33
|
+
"""A RunFatal type declares that the whole run ends."""
|
|
34
|
+
content = (
|
|
35
|
+
"def run(all_members):\n"
|
|
36
|
+
" for each_member in all_members:\n"
|
|
37
|
+
" if each_member.digest_differs:\n"
|
|
38
|
+
" raise AssetRunFatal('source bytes changed')\n"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_should_accept_an_item_blocked_raise_inside_a_loop() -> None:
|
|
45
|
+
"""An ItemBlocked type declares a member-scoped stop."""
|
|
46
|
+
content = (
|
|
47
|
+
"def run(all_members):\n"
|
|
48
|
+
" for each_member in all_members:\n"
|
|
49
|
+
" if each_member.requires_resize:\n"
|
|
50
|
+
" raise AssetItemBlocked('member requires resizing')\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_should_accept_a_raise_wrapped_by_a_blast_radius_boundary() -> None:
|
|
57
|
+
"""A per-member boundary catches a declared type and parks the failure."""
|
|
58
|
+
content = (
|
|
59
|
+
"def run(all_members):\n"
|
|
60
|
+
" for each_member in all_members:\n"
|
|
61
|
+
" try:\n"
|
|
62
|
+
" if each_member.requires_resize:\n"
|
|
63
|
+
" raise AssetItemBlocked('member requires resizing')\n"
|
|
64
|
+
" except AssetItemBlocked as failure:\n"
|
|
65
|
+
" park(each_member, failure)\n"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_report_runtime_error_with_a_different_declared_handler() -> None:
|
|
72
|
+
"""A different declared handler leaves a runtime crash requiring a name."""
|
|
73
|
+
content = (
|
|
74
|
+
"def run(all_members):\n"
|
|
75
|
+
" for each_member in all_members:\n"
|
|
76
|
+
" try:\n"
|
|
77
|
+
" raise RuntimeError('code defect')\n"
|
|
78
|
+
" except AssetItemBlocked:\n"
|
|
79
|
+
" park(each_member)\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("raised_type", ["RuntimeError", "TypeError", "AttributeError", "ValueError"])
|
|
86
|
+
def test_should_require_corresponding_handler_for_each_explicit_raise(
|
|
87
|
+
raised_type: str,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Each explicit raise needs a handler naming that same type."""
|
|
90
|
+
content = (
|
|
91
|
+
"def run(all_members):\n"
|
|
92
|
+
" for each_member in all_members:\n"
|
|
93
|
+
" try:\n"
|
|
94
|
+
f" raise {raised_type}('code defect')\n"
|
|
95
|
+
" except AssetItemBlocked:\n"
|
|
96
|
+
" park(each_member)\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_accept_a_run_level_raise_during_manifest_preparation() -> None:
|
|
103
|
+
"""A run-level raise belongs to manifest preparation."""
|
|
104
|
+
content = (
|
|
105
|
+
"def prepare(manifest):\n"
|
|
106
|
+
" if manifest.requires_preparation:\n"
|
|
107
|
+
" raise AssetError('manifest requires preparation')\n"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_accept_a_bare_reraise_inside_a_loop() -> None:
|
|
114
|
+
"""A bare re-raise propagates an error carrying a declared radius."""
|
|
115
|
+
content = (
|
|
116
|
+
"def run(all_members):\n"
|
|
117
|
+
" for each_member in all_members:\n"
|
|
118
|
+
" try:\n"
|
|
119
|
+
" process(each_member)\n"
|
|
120
|
+
" except AssetItemBlocked:\n"
|
|
121
|
+
" raise\n"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_accept_test_files() -> None:
|
|
128
|
+
"""Test modules raise freely during scenario coverage."""
|
|
129
|
+
content = (
|
|
130
|
+
"def test_records_member_failure(all_members):\n"
|
|
131
|
+
" for each_member in all_members:\n"
|
|
132
|
+
" raise AssetError('member failure')\n"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert check_blast_radius_declared(content, "pipeline/test_asset_run.py") == []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_should_report_each_loop_raise_with_pending_blast_radius_declaration() -> None:
|
|
139
|
+
"""Each loop-body raise gets an advisory requesting its blast-radius declaration."""
|
|
140
|
+
content = (
|
|
141
|
+
"def run(all_members):\n"
|
|
142
|
+
" for each_member in all_members:\n"
|
|
143
|
+
" if each_member.requires_source:\n"
|
|
144
|
+
" raise AssetError('member requires a source')\n"
|
|
145
|
+
" if each_member.requires_resize:\n"
|
|
146
|
+
" raise AssetError('member requires resizing')\n"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_ignore_raise_in_nested_helper_body() -> None:
|
|
153
|
+
"""A nested helper body is checked at its own call boundary."""
|
|
154
|
+
content = (
|
|
155
|
+
"def run(all_members):\n"
|
|
156
|
+
" for each_member in all_members:\n"
|
|
157
|
+
" def validate_member():\n"
|
|
158
|
+
" raise AssetError('member validation failed')\n"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
@@ -12,20 +12,21 @@ so the repository code rules do not govern it::
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import os
|
|
15
|
-
import
|
|
16
|
-
import sys
|
|
15
|
+
from subprocess import CompletedProcess
|
|
17
16
|
import tempfile
|
|
18
17
|
from pathlib import Path
|
|
19
18
|
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
20
21
|
from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
|
|
22
|
+
import code_rules_enforcer_test_support
|
|
21
23
|
from blocking.code_rules_shared import is_agent_home_tooling, is_ephemeral_path
|
|
22
|
-
|
|
23
|
-
_ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
24
|
+
from code_rules_enforcer_test_support import run_precheck
|
|
24
25
|
|
|
25
26
|
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
def _check_as(candidate_source: str, target_path: str) ->
|
|
29
|
+
def _check_as(candidate_source: str, target_path: str) -> CompletedProcess[str]:
|
|
29
30
|
"""Run the enforcer's pre-check on a candidate judged as another path.
|
|
30
31
|
|
|
31
32
|
Args:
|
|
@@ -38,13 +39,37 @@ def _check_as(candidate_source: str, target_path: str) -> subprocess.CompletedPr
|
|
|
38
39
|
with tempfile.TemporaryDirectory() as scratch:
|
|
39
40
|
candidate = Path(scratch) / "candidate.py"
|
|
40
41
|
candidate.write_text(candidate_source, encoding="utf-8")
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
return run_precheck(candidate, target_path, None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_run_precheck_forwards_environment_mapping(
|
|
46
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
47
|
+
) -> None:
|
|
48
|
+
expected_environment_by_name = {"CLAUDE_JOB_DIR": "/agent/jobs"}
|
|
49
|
+
captured_environment_by_name: dict[str, str] | None = None
|
|
50
|
+
|
|
51
|
+
def capture_run_enforcer_cli(
|
|
52
|
+
_script_path: Path,
|
|
53
|
+
_all_arguments: list[str],
|
|
54
|
+
extra_environment_by_name: dict[str, str] | None,
|
|
55
|
+
) -> CompletedProcess[str]:
|
|
56
|
+
nonlocal captured_environment_by_name
|
|
57
|
+
captured_environment_by_name = extra_environment_by_name
|
|
58
|
+
return CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(
|
|
61
|
+
code_rules_enforcer_test_support,
|
|
62
|
+
"run_enforcer_cli",
|
|
63
|
+
capture_run_enforcer_cli,
|
|
64
|
+
)
|
|
65
|
+
completed = run_precheck(
|
|
66
|
+
Path("candidate.py"),
|
|
67
|
+
"target.py",
|
|
68
|
+
expected_environment_by_name,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
assert completed.returncode == 0
|
|
72
|
+
assert captured_environment_by_name == {"CLAUDE_JOB_DIR": "/agent/jobs"}
|
|
48
73
|
|
|
49
74
|
|
|
50
75
|
def test_posix_agent_home_path_is_agent_tooling() -> None:
|