claude-dev-env 1.89.0 → 1.92.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/CLAUDE.md +0 -13
- package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- package/agents/clean-coder.md +2 -2
- package/agents/code-verifier.md +0 -1
- package/agents/test_agent_frontmatter.py +78 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/bin/install.mjs +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +6 -2
- package/hooks/blocking/code_rules_comments.py +2 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
- package/hooks/blocking/config/verified_commit_constants.py +2 -0
- package/hooks/blocking/conftest.py +115 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
- package/hooks/blocking/pr_description_enforcer.py +46 -22
- package/hooks/blocking/pr_description_pr_number.py +5 -3
- package/hooks/blocking/pr_description_proof_of_work.py +367 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
- package/hooks/blocking/test_verification_verdict_store.py +11 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
- package/hooks/blocking/test_verified_commit_gate.py +11 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
- package/hooks/blocking/verification_verdict_store.py +73 -5
- package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
- package/hooks/blocking/verified_commit_gate.py +6 -0
- package/hooks/blocking/verifier_verdict_minter.py +6 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +32 -2
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
- package/hooks/validators/run_all_validators.py +216 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/scripts/CLAUDE.md +2 -0
- package/scripts/claude-chain.example.json +8 -0
- package/scripts/claude_chain_runner.py +400 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/team-advisor/SKILL.md +188 -0
- package/skills/team-advisor-refresh/SKILL.md +25 -0
- package/skills/usage-pause/SKILL.md +108 -0
- package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
- package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
- package/system-prompts/software-engineer.xml +3 -2
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Configuration constants for the volatile_path_in_post_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
BASH_TOOL_NAME: str = "Bash"
|
|
4
|
+
|
|
5
|
+
MCP_GITHUB_TOOL_PREFIX: str = "mcp__plugin_github_github__"
|
|
6
|
+
|
|
7
|
+
ALL_MCP_BODY_PARAM_NAMES: tuple[str, ...] = ("body", "comment")
|
|
8
|
+
|
|
9
|
+
GH_COMMAND_NAME: str = "gh"
|
|
10
|
+
|
|
11
|
+
MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT: int = 2
|
|
12
|
+
|
|
13
|
+
TOKEN_JOIN_SEPARATOR: str = " "
|
|
14
|
+
|
|
15
|
+
BODY_FILE_ENCODING: str = "utf-8"
|
|
16
|
+
|
|
17
|
+
ALL_GH_POST_SUBCOMMANDS: dict[str, frozenset[str]] = {
|
|
18
|
+
"pr": frozenset({"create", "comment", "edit", "review"}),
|
|
19
|
+
"issue": frozenset({"create", "comment", "edit"}),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ALL_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
23
|
+
".claude-editor/jobs/",
|
|
24
|
+
".claude/worktrees/",
|
|
25
|
+
"appdata/local/temp",
|
|
26
|
+
"/tmp/",
|
|
27
|
+
"%temp%",
|
|
28
|
+
"$env:temp",
|
|
29
|
+
"$claude_job_dir",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
GH_ARTIFACT_UPLOAD_INVOCATION: str = (
|
|
33
|
+
"python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
CORRECTIVE_MESSAGE: str = (
|
|
37
|
+
"BLOCKED [durable-post-artifacts]: this post body references a volatile path "
|
|
38
|
+
"(a job scratch dir, worktree, or system temp location). The post is durable "
|
|
39
|
+
"and outlives that scratch, so the reference breaks the moment the directory "
|
|
40
|
+
"is cleaned.\n\n"
|
|
41
|
+
"Fix it before posting:\n"
|
|
42
|
+
" 1. Text data (logs, tables, diffs): paste the actual content inline in the "
|
|
43
|
+
"post instead of linking a scratch file path.\n"
|
|
44
|
+
" 2. Binary artifacts (images, archives): upload the file to the repo's "
|
|
45
|
+
"durable 'artifacts' release and link the permanent asset URL it prints:\n"
|
|
46
|
+
f" {GH_ARTIFACT_UPLOAD_INVOCATION}\n\n"
|
|
47
|
+
"See ~/.claude/rules/durable-post-artifacts.md for the full contract."
|
|
48
|
+
)
|
|
@@ -7,12 +7,14 @@ Hooks that run at session or config-change boundaries rather than on individual
|
|
|
7
7
|
| File | Event | What it does |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `config_change_guard.py` | PostToolUse (Write/Edit on `settings.json`) | Counts hooks in the edited `settings.json` and logs any change to `~/.claude/cache/config-change-audit.log`; alerts when the hook count drops below the last known value |
|
|
10
|
+
| `enter_worktree_origin_prefetch.py` | PreToolUse (EnterWorktree) | Fetches origin's default branch before a worktree creation call, keeping the `refs/remotes/origin/<default-branch>` ref that `fresh` mode reads current; never blocks on fetch failure |
|
|
10
11
|
| `pr_converge_bugteam_skill_tracker.py` | PreToolUse (Skill) | Tracks which bugteam skill runs have completed within a pr-converge loop, so the enforcer can verify parallel execution |
|
|
11
12
|
| `session_end_cleanup.py` | SessionEnd | Purges stale cache entries from `~/.claude/cache/` (entries older than the configured threshold) and old backup files |
|
|
12
13
|
| `test_config_change_guard.py` | — | Tests for `config_change_guard.py` |
|
|
14
|
+
| `test_enter_worktree_origin_prefetch.py` | — | Tests for `enter_worktree_origin_prefetch.py` |
|
|
13
15
|
| `test_pr_converge_bugteam_skill_tracker.py` | — | Tests for `pr_converge_bugteam_skill_tracker.py` |
|
|
14
16
|
|
|
15
17
|
## Conventions
|
|
16
18
|
|
|
17
|
-
- Constants for these hooks (stale-age threshold, cache directory, known-hook-count file) live in `hooks_constants/session_env_cleanup_constants.py` and `hooks_constants/
|
|
19
|
+
- Constants for these hooks (stale-age threshold, cache directory, known-hook-count file, EnterWorktree prefetch tuning) live in `hooks_constants/session_env_cleanup_constants.py`, `hooks_constants/pr_converge_bugteam_enforcer_constants.py`, and `hooks_constants/enter_worktree_prefetch_constants.py`.
|
|
18
20
|
- Tests run with `python -m pytest lifecycle/test_<name>.py`.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: fetch origin's default branch before EnterWorktree creates one.
|
|
3
|
+
|
|
4
|
+
``EnterWorktree`` in its default ``fresh`` mode bases a new worktree on the
|
|
5
|
+
locally cached ``refs/remotes/origin/<default-branch>`` ref and only runs
|
|
6
|
+
``git fetch`` when that ref is missing entirely -- so a worktree created
|
|
7
|
+
without a recent fetch silently starts behind the remote. This hook fetches
|
|
8
|
+
that ref immediately before the tool resolves its base, so the ref it reads
|
|
9
|
+
is current.
|
|
10
|
+
|
|
11
|
+
::
|
|
12
|
+
|
|
13
|
+
EnterWorktree({}) # tool_input has no "path"
|
|
14
|
+
|
|
|
15
|
+
v
|
|
16
|
+
this hook: git fetch origin <default-branch> # best-effort, never blocks
|
|
17
|
+
|
|
|
18
|
+
v
|
|
19
|
+
EnterWorktree resolves refs/remotes/origin/<default-branch> # now fresh
|
|
20
|
+
|
|
21
|
+
The hook is a no-op when ``tool_input`` carries a ``path`` (switching into an
|
|
22
|
+
already-existing worktree needs no fresh base) and always exits 0 -- a failed
|
|
23
|
+
or slow fetch never blocks worktree creation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
34
|
+
if _hooks_dir not in sys.path:
|
|
35
|
+
sys.path.insert(0, _hooks_dir)
|
|
36
|
+
|
|
37
|
+
from hooks_constants.enter_worktree_prefetch_constants import ( # noqa: E402
|
|
38
|
+
ENTER_WORKTREE_PATH_INPUT_KEY,
|
|
39
|
+
ENTER_WORKTREE_TOOL_NAME,
|
|
40
|
+
GIT_FETCH_TIMEOUT_SECONDS,
|
|
41
|
+
GIT_SYMBOLIC_REF_TIMEOUT_SECONDS,
|
|
42
|
+
ORIGIN_HEAD_SYMBOLIC_REF,
|
|
43
|
+
ORIGIN_REMOTE_NAME,
|
|
44
|
+
ORIGIN_REMOTE_REF_PREFIX,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_enter_worktree_creation(payload_by_field: dict[str, object]) -> bool:
|
|
49
|
+
"""Return True when this hook invocation is an EnterWorktree creation call.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
|
|
53
|
+
keyed by top-level field name.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True when ``tool_name == "EnterWorktree"`` and ``tool_input`` carries
|
|
57
|
+
no ``path`` key. A ``path`` value means the call switches into an
|
|
58
|
+
already-existing worktree rather than creating one, so it is False
|
|
59
|
+
for that case and for every other tool.
|
|
60
|
+
"""
|
|
61
|
+
if payload_by_field.get("tool_name", "") != ENTER_WORKTREE_TOOL_NAME:
|
|
62
|
+
return False
|
|
63
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
64
|
+
if not isinstance(tool_input, dict):
|
|
65
|
+
return True
|
|
66
|
+
return not tool_input.get(ENTER_WORKTREE_PATH_INPUT_KEY)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_origin_default_branch(repo_directory: str) -> str | None:
|
|
70
|
+
"""Return the branch name origin's HEAD points at, or None if unresolved.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
repo_directory: Working directory to run ``git`` in.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The branch name (for example ``"main"``) parsed from
|
|
77
|
+
``refs/remotes/origin/HEAD``, or None when the symbolic ref is
|
|
78
|
+
unset, git is unavailable, or the command times out.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
completed_process = subprocess.run(
|
|
82
|
+
["git", "symbolic-ref", ORIGIN_HEAD_SYMBOLIC_REF],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=GIT_SYMBOLIC_REF_TIMEOUT_SECONDS,
|
|
86
|
+
cwd=repo_directory,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
90
|
+
return None
|
|
91
|
+
if completed_process.returncode != 0:
|
|
92
|
+
return None
|
|
93
|
+
resolved_ref = completed_process.stdout.strip()
|
|
94
|
+
if not resolved_ref.startswith(ORIGIN_REMOTE_REF_PREFIX):
|
|
95
|
+
return None
|
|
96
|
+
return resolved_ref[len(ORIGIN_REMOTE_REF_PREFIX):]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fetch_origin_branch(repo_directory: str, branch_name: str) -> None:
|
|
100
|
+
"""Best-effort ``git fetch origin <branch_name>``; never raises.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
repo_directory: Working directory to run ``git`` in.
|
|
104
|
+
branch_name: Branch to fetch from the ``origin`` remote.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
subprocess.run(
|
|
108
|
+
["git", "fetch", ORIGIN_REMOTE_NAME, branch_name],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
timeout=GIT_FETCH_TIMEOUT_SECONDS,
|
|
112
|
+
cwd=repo_directory,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
"""Entry point for the PreToolUse:EnterWorktree hook.
|
|
121
|
+
|
|
122
|
+
Reads the PreToolUse payload from stdin, and when it is an EnterWorktree
|
|
123
|
+
creation call, fetches origin's default branch in the session's ``cwd``
|
|
124
|
+
before returning. Always exits 0: a fetch failure self-heals on the next
|
|
125
|
+
fetch rather than blocking worktree creation.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
hook_payload = json.load(sys.stdin)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
if not isinstance(hook_payload, dict):
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
if not is_enter_worktree_creation(hook_payload):
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
repo_directory = hook_payload.get("cwd")
|
|
136
|
+
if not isinstance(repo_directory, str) or not repo_directory:
|
|
137
|
+
sys.exit(0)
|
|
138
|
+
default_branch = resolve_origin_default_branch(repo_directory)
|
|
139
|
+
if default_branch is None:
|
|
140
|
+
sys.exit(0)
|
|
141
|
+
fetch_origin_branch(repo_directory, default_branch)
|
|
142
|
+
sys.exit(0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Unit tests for the enter_worktree_origin_prefetch PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Uses real git repositories (a "remote" repo cloned into a "local" one) so the
|
|
4
|
+
fetch behavior is exercised against actual git plumbing, not mocked calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import pathlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
19
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
20
|
+
for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
|
|
21
|
+
if each_path not in sys.path:
|
|
22
|
+
sys.path.insert(0, each_path)
|
|
23
|
+
|
|
24
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
25
|
+
"enter_worktree_origin_prefetch",
|
|
26
|
+
_HOOK_DIR / "enter_worktree_origin_prefetch.py",
|
|
27
|
+
)
|
|
28
|
+
assert hook_spec is not None
|
|
29
|
+
assert hook_spec.loader is not None
|
|
30
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
31
|
+
hook_spec.loader.exec_module(hook_module)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_git(repo_directory: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
["git", *args],
|
|
37
|
+
cwd=repo_directory,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
check=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _init_repo_with_commit(repo_directory: pathlib.Path) -> None:
|
|
45
|
+
repo_directory.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
_run_git(repo_directory, "init", "--quiet", "--initial-branch=main")
|
|
47
|
+
_run_git(repo_directory, "config", "user.email", "test@example.com")
|
|
48
|
+
_run_git(repo_directory, "config", "user.name", "Test")
|
|
49
|
+
_run_git(repo_directory, "commit", "--allow-empty", "--quiet", "-m", "init")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clone_as_local(remote_directory: pathlib.Path, local_directory: pathlib.Path) -> None:
|
|
53
|
+
subprocess.run(
|
|
54
|
+
["git", "clone", "--quiet", str(remote_directory), str(local_directory)],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
check=True,
|
|
58
|
+
)
|
|
59
|
+
_run_git(local_directory, "config", "user.email", "test@example.com")
|
|
60
|
+
_run_git(local_directory, "config", "user.name", "Test")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_is_enter_worktree_creation_true_when_no_path_given() -> None:
|
|
64
|
+
payload = {"tool_name": "EnterWorktree", "tool_input": {}}
|
|
65
|
+
assert hook_module.is_enter_worktree_creation(payload) is True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_is_enter_worktree_creation_true_with_name_only() -> None:
|
|
69
|
+
payload = {"tool_name": "EnterWorktree", "tool_input": {"name": "my-branch"}}
|
|
70
|
+
assert hook_module.is_enter_worktree_creation(payload) is True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_is_enter_worktree_creation_false_when_path_given() -> None:
|
|
74
|
+
payload = {"tool_name": "EnterWorktree", "tool_input": {"path": "/some/worktree"}}
|
|
75
|
+
assert hook_module.is_enter_worktree_creation(payload) is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_is_enter_worktree_creation_false_for_other_tool() -> None:
|
|
79
|
+
payload = {"tool_name": "Bash", "tool_input": {}}
|
|
80
|
+
assert hook_module.is_enter_worktree_creation(payload) is False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_resolve_origin_default_branch_reads_cloned_head(tmp_path: pathlib.Path) -> None:
|
|
84
|
+
remote_directory = tmp_path / "remote"
|
|
85
|
+
local_directory = tmp_path / "local"
|
|
86
|
+
_init_repo_with_commit(remote_directory)
|
|
87
|
+
_clone_as_local(remote_directory, local_directory)
|
|
88
|
+
|
|
89
|
+
resolved_branch = hook_module.resolve_origin_default_branch(str(local_directory))
|
|
90
|
+
|
|
91
|
+
assert resolved_branch == "main"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_resolve_origin_default_branch_returns_none_without_origin(
|
|
95
|
+
tmp_path: pathlib.Path,
|
|
96
|
+
) -> None:
|
|
97
|
+
solo_directory = tmp_path / "solo"
|
|
98
|
+
_init_repo_with_commit(solo_directory)
|
|
99
|
+
|
|
100
|
+
resolved_branch = hook_module.resolve_origin_default_branch(str(solo_directory))
|
|
101
|
+
|
|
102
|
+
assert resolved_branch is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_fetch_origin_branch_updates_stale_local_ref(tmp_path: pathlib.Path) -> None:
|
|
106
|
+
remote_directory = tmp_path / "remote"
|
|
107
|
+
local_directory = tmp_path / "local"
|
|
108
|
+
_init_repo_with_commit(remote_directory)
|
|
109
|
+
_clone_as_local(remote_directory, local_directory)
|
|
110
|
+
|
|
111
|
+
_run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
|
|
112
|
+
remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
|
|
113
|
+
local_cached_sha_before = _run_git(
|
|
114
|
+
local_directory, "rev-parse", "refs/remotes/origin/main"
|
|
115
|
+
).stdout.strip()
|
|
116
|
+
assert local_cached_sha_before != remote_head_sha
|
|
117
|
+
|
|
118
|
+
hook_module.fetch_origin_branch(str(local_directory), "main")
|
|
119
|
+
|
|
120
|
+
local_cached_sha_after = _run_git(
|
|
121
|
+
local_directory, "rev-parse", "refs/remotes/origin/main"
|
|
122
|
+
).stdout.strip()
|
|
123
|
+
assert local_cached_sha_after == remote_head_sha
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_fetch_origin_branch_never_raises_when_remote_missing(tmp_path: pathlib.Path) -> None:
|
|
127
|
+
solo_directory = tmp_path / "solo"
|
|
128
|
+
_init_repo_with_commit(solo_directory)
|
|
129
|
+
|
|
130
|
+
hook_module.fetch_origin_branch(str(solo_directory), "main")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_main_fetches_stale_ref_and_exits_zero(
|
|
134
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
135
|
+
) -> None:
|
|
136
|
+
remote_directory = tmp_path / "remote"
|
|
137
|
+
local_directory = tmp_path / "local"
|
|
138
|
+
_init_repo_with_commit(remote_directory)
|
|
139
|
+
_clone_as_local(remote_directory, local_directory)
|
|
140
|
+
_run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
|
|
141
|
+
remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
|
|
142
|
+
|
|
143
|
+
payload = {
|
|
144
|
+
"tool_name": "EnterWorktree",
|
|
145
|
+
"tool_input": {},
|
|
146
|
+
"cwd": str(local_directory),
|
|
147
|
+
}
|
|
148
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
149
|
+
|
|
150
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
151
|
+
hook_module.main()
|
|
152
|
+
|
|
153
|
+
assert exit_info.value.code == 0
|
|
154
|
+
local_cached_sha_after = _run_git(
|
|
155
|
+
local_directory, "rev-parse", "refs/remotes/origin/main"
|
|
156
|
+
).stdout.strip()
|
|
157
|
+
assert local_cached_sha_after == remote_head_sha
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_main_exits_zero_for_non_enter_worktree_tool(
|
|
161
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
162
|
+
) -> None:
|
|
163
|
+
payload = {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": "/tmp"}
|
|
164
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
165
|
+
|
|
166
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
167
|
+
hook_module.main()
|
|
168
|
+
|
|
169
|
+
assert exit_info.value.code == 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_main_exits_zero_on_malformed_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
173
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
174
|
+
|
|
175
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
176
|
+
hook_module.main()
|
|
177
|
+
|
|
178
|
+
assert exit_info.value.code == 0
|
|
@@ -6,6 +6,7 @@ Exit code 0 = all checks pass, 1 = violations found.
|
|
|
6
6
|
# pragma: no-tdd-gate
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import json
|
|
9
10
|
import os
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
@@ -27,6 +28,15 @@ VALIDATORS_DIR = Path(__file__).parent
|
|
|
27
28
|
hooks_dir = VALIDATORS_DIR.parent
|
|
28
29
|
package_name = VALIDATORS_DIR.name
|
|
29
30
|
|
|
31
|
+
_hooks_directory_on_path = str(hooks_dir.resolve())
|
|
32
|
+
if _hooks_directory_on_path not in sys.path:
|
|
33
|
+
sys.path.insert(0, _hooks_directory_on_path)
|
|
34
|
+
|
|
35
|
+
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
36
|
+
apply_edits,
|
|
37
|
+
edits_for_tool,
|
|
38
|
+
)
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
def _windows_non_unc_working_directory_string(
|
|
32
42
|
candidate_directory_strings: list[str | None],
|
|
@@ -99,8 +109,19 @@ def invoke_validator_module(module_stem: str, forwarded_file_paths: List[str]) -
|
|
|
99
109
|
|
|
100
110
|
def run_validators_entrypoint_subprocess(
|
|
101
111
|
extra_arguments: List[str],
|
|
112
|
+
stdin_text: Optional[str] = None,
|
|
102
113
|
) -> subprocess.CompletedProcess[str]:
|
|
103
|
-
"""Run ``python -m validators.run_all_validators`` with a Windows-safe cwd.
|
|
114
|
+
"""Run ``python -m validators.run_all_validators`` with a Windows-safe cwd.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
extra_arguments: Argument vector appended after the module name.
|
|
118
|
+
stdin_text: Text replayed as the subprocess stdin, or None to leave
|
|
119
|
+
stdin empty. The PreToolUse gate mode reads its payload from stdin,
|
|
120
|
+
so a caller exercising ``--pre-tool-use`` passes the payload here.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The completed subprocess carrying its captured stdout and stderr.
|
|
124
|
+
"""
|
|
104
125
|
working_directory_string, environment = (
|
|
105
126
|
_hooks_subprocess_working_directory_and_environment()
|
|
106
127
|
)
|
|
@@ -111,6 +132,7 @@ def run_validators_entrypoint_subprocess(
|
|
|
111
132
|
text=True,
|
|
112
133
|
cwd=working_directory_string,
|
|
113
134
|
env=environment,
|
|
135
|
+
input=stdin_text,
|
|
114
136
|
)
|
|
115
137
|
|
|
116
138
|
|
|
@@ -375,9 +397,9 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
|
|
|
375
397
|
"""Comment preservation is enforced by code_rules_enforcer hook.
|
|
376
398
|
|
|
377
399
|
The hook compares old vs new content to block NEW comments and
|
|
378
|
-
|
|
379
|
-
is disabled because it flags ALL comments in
|
|
380
|
-
which forces agents to remove them to pass validation.
|
|
400
|
+
print a stderr advisory when an existing comment is removed. This
|
|
401
|
+
standalone validator is disabled because it flags ALL comments in
|
|
402
|
+
existing files, which forces agents to remove them to pass validation.
|
|
381
403
|
"""
|
|
382
404
|
return ValidatorResult(
|
|
383
405
|
name="No Comments",
|
|
@@ -658,6 +680,188 @@ def get_changed_files() -> List[Path]:
|
|
|
658
680
|
return [Path(f) for f in files if f]
|
|
659
681
|
|
|
660
682
|
|
|
683
|
+
def _read_target_file_content(file_path: str) -> Optional[str]:
|
|
684
|
+
"""Return the on-disk content of *file_path*, or None when it cannot be read."""
|
|
685
|
+
try:
|
|
686
|
+
with open(file_path, "r", encoding="utf-8") as readable_file:
|
|
687
|
+
return readable_file.read()
|
|
688
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def reconstruct_proposed_content(
|
|
693
|
+
tool_name: str, tool_input: Dict[str, object]
|
|
694
|
+
) -> Optional[str]:
|
|
695
|
+
"""Return the post-edit content one Write, Edit, or MultiEdit payload leaves on disk.
|
|
696
|
+
|
|
697
|
+
::
|
|
698
|
+
|
|
699
|
+
Write -> tool_input["content"] verbatim
|
|
700
|
+
Edit -> existing file, each old_string rewritten to new_string
|
|
701
|
+
MultiEdit -> existing file, each edit applied in order
|
|
702
|
+
|
|
703
|
+
The Edit and MultiEdit reconstruction reuses the shared applier so this gate
|
|
704
|
+
judges the same post-edit content the standalone blockers judge.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
tool_name: The intercepted tool — Write, Edit, or MultiEdit.
|
|
708
|
+
tool_input: The tool's input payload.
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
The proposed post-edit content, or None when the payload carries no
|
|
712
|
+
readable target for an edit or no string content for a write.
|
|
713
|
+
"""
|
|
714
|
+
if tool_name == "Write":
|
|
715
|
+
written_content = tool_input.get("content", "")
|
|
716
|
+
return written_content if isinstance(written_content, str) else None
|
|
717
|
+
file_path = tool_input.get("file_path", "")
|
|
718
|
+
if not isinstance(file_path, str) or not file_path:
|
|
719
|
+
return None
|
|
720
|
+
existing_content = _read_target_file_content(file_path)
|
|
721
|
+
if existing_content is None:
|
|
722
|
+
return None
|
|
723
|
+
return apply_edits(existing_content, edits_for_tool(tool_name, dict(tool_input)))
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
|
|
727
|
+
"""Run every validator scoped to individual files against *all_files*.
|
|
728
|
+
|
|
729
|
+
Excludes the branch-scoped File Structure and Git validators, which grade
|
|
730
|
+
the whole project rather than a single proposed file.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
all_files: The files under validation — a single reconstructed file in
|
|
734
|
+
gate mode.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
One ValidatorResult per file-scoped validator, in run order.
|
|
738
|
+
"""
|
|
739
|
+
return [
|
|
740
|
+
run_python_style_checks(all_files),
|
|
741
|
+
run_test_safety_checks(all_files),
|
|
742
|
+
run_react_checks(all_files),
|
|
743
|
+
run_ruff_checks(all_files),
|
|
744
|
+
run_mypy_checks(all_files),
|
|
745
|
+
run_abbreviation_checks(all_files),
|
|
746
|
+
run_pr_reference_checks(all_files),
|
|
747
|
+
run_magic_value_checks(all_files),
|
|
748
|
+
run_useless_test_checks(all_files),
|
|
749
|
+
run_security_checks(all_files),
|
|
750
|
+
run_code_quality_checks(all_files),
|
|
751
|
+
run_python_antipattern_checks(all_files),
|
|
752
|
+
run_todo_checks(all_files),
|
|
753
|
+
run_type_safety_checks(all_files),
|
|
754
|
+
]
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def validate_proposed_file(
|
|
758
|
+
file_path: str, proposed_content: str
|
|
759
|
+
) -> List[ValidatorResult]:
|
|
760
|
+
"""Validate *proposed_content* as if written to *file_path*.
|
|
761
|
+
|
|
762
|
+
Writes the content to a temporary file that carries the target's basename so
|
|
763
|
+
suffix-based and test-name-based validator filtering matches the real path,
|
|
764
|
+
then runs the file-scoped validators against it.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
file_path: The destination path the write or edit targets.
|
|
768
|
+
proposed_content: The reconstructed post-edit content of that file.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
One ValidatorResult per file-scoped validator.
|
|
772
|
+
"""
|
|
773
|
+
base_name = Path(file_path).name
|
|
774
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
775
|
+
temporary_file = Path(temporary_directory) / base_name
|
|
776
|
+
temporary_file.write_text(proposed_content, encoding="utf-8")
|
|
777
|
+
return run_file_scoped_validators([temporary_file])
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _proposed_content_deny_reason(failed_results: List[ValidatorResult]) -> str:
|
|
781
|
+
"""Compose the deny reason naming each failing validator and its output.
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
failed_results: The validator results that did not pass.
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
The composed ``permissionDecisionReason`` text.
|
|
788
|
+
"""
|
|
789
|
+
violation_summaries = [
|
|
790
|
+
f"{each_result.name} (checks {each_result.checks}): {each_result.output.strip()}"
|
|
791
|
+
for each_result in failed_results
|
|
792
|
+
]
|
|
793
|
+
deny_reason_item_separator = " | "
|
|
794
|
+
joined_summaries = deny_reason_item_separator.join(violation_summaries)
|
|
795
|
+
return (
|
|
796
|
+
f"BLOCKED: [validators] {len(failed_results)} "
|
|
797
|
+
f"validator(s) failed: {joined_summaries}"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _emit_pre_tool_use_deny(deny_reason: str) -> None:
|
|
802
|
+
"""Write one PreToolUse deny JSON payload carrying *deny_reason* to stdout."""
|
|
803
|
+
deny_payload = {
|
|
804
|
+
"hookSpecificOutput": {
|
|
805
|
+
"hookEventName": "PreToolUse",
|
|
806
|
+
"permissionDecision": "deny",
|
|
807
|
+
"permissionDecisionReason": deny_reason,
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
sys.stdout.write(json.dumps(deny_payload) + "\n")
|
|
811
|
+
sys.stdout.flush()
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _evaluate_pre_tool_use_payload() -> None:
|
|
815
|
+
"""Read the PreToolUse payload from stdin and deny a violating file write.
|
|
816
|
+
|
|
817
|
+
Reconstructs the proposed post-edit content of the one target file and runs
|
|
818
|
+
the file-scoped validators against it. Emits a deny decision naming each
|
|
819
|
+
failing validator when any fires; writes nothing for a clean file, an
|
|
820
|
+
unparseable payload, or a payload no validator covers.
|
|
821
|
+
"""
|
|
822
|
+
pre_tool_use_payload = json.load(sys.stdin)
|
|
823
|
+
if not isinstance(pre_tool_use_payload, dict):
|
|
824
|
+
return
|
|
825
|
+
tool_name = pre_tool_use_payload.get("tool_name", "")
|
|
826
|
+
tool_input = pre_tool_use_payload.get("tool_input", {})
|
|
827
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
828
|
+
return
|
|
829
|
+
file_path = tool_input.get("file_path", "")
|
|
830
|
+
if not isinstance(file_path, str) or not file_path:
|
|
831
|
+
return
|
|
832
|
+
proposed_content = reconstruct_proposed_content(tool_name, tool_input)
|
|
833
|
+
if not proposed_content:
|
|
834
|
+
return
|
|
835
|
+
all_results = validate_proposed_file(file_path, proposed_content)
|
|
836
|
+
failed_results = [
|
|
837
|
+
each_result
|
|
838
|
+
for each_result in all_results
|
|
839
|
+
if not each_result.passed and not each_result.skipped
|
|
840
|
+
]
|
|
841
|
+
if failed_results:
|
|
842
|
+
_emit_pre_tool_use_deny(_proposed_content_deny_reason(failed_results))
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def run_pre_tool_use_gate() -> int:
|
|
846
|
+
"""Run the PreToolUse gate, never crashing the tool call on an internal error.
|
|
847
|
+
|
|
848
|
+
A hook that raises is rendered by the harness as a tool malfunction, so an
|
|
849
|
+
unexpected failure here logs to stderr and returns 0 rather than propagating.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
Always 0 — the gate signals a block through the deny payload, not an
|
|
853
|
+
exit code.
|
|
854
|
+
"""
|
|
855
|
+
try:
|
|
856
|
+
_evaluate_pre_tool_use_payload()
|
|
857
|
+
except json.JSONDecodeError:
|
|
858
|
+
return 0
|
|
859
|
+
except Exception as error:
|
|
860
|
+
sys.stderr.write(f"[run_all_validators] pre-tool-use gate error: {error}\n")
|
|
861
|
+
sys.stderr.flush()
|
|
862
|
+
return 0
|
|
863
|
+
|
|
864
|
+
|
|
661
865
|
def main() -> int:
|
|
662
866
|
"""Run all validators and report results."""
|
|
663
867
|
parser = argparse.ArgumentParser(description="Run pre-push validators")
|
|
@@ -687,8 +891,16 @@ def main() -> int:
|
|
|
687
891
|
default=2,
|
|
688
892
|
help="Lines of context around violations",
|
|
689
893
|
)
|
|
894
|
+
parser.add_argument(
|
|
895
|
+
"--pre-tool-use",
|
|
896
|
+
action="store_true",
|
|
897
|
+
help="Run as a PreToolUse gate on the single proposed file write from stdin",
|
|
898
|
+
)
|
|
690
899
|
args = parser.parse_args()
|
|
691
900
|
|
|
901
|
+
if args.pre_tool_use:
|
|
902
|
+
return run_pre_tool_use_gate()
|
|
903
|
+
|
|
692
904
|
if args.health:
|
|
693
905
|
health = get_system_health()
|
|
694
906
|
print_health_report(health)
|