claude-dev-env 1.72.0 → 1.73.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/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +616 -0
- package/hooks/blocking/code_rules_enforcer.py +22 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +61 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/autoconverge/SKILL.md +93 -0
- package/skills/autoconverge/workflow/converge.mjs +27 -2
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Same-decision tests for hooks converted to the shared stdin parser.
|
|
2
|
+
|
|
3
|
+
Each converted hook reads its PreToolUse payload through
|
|
4
|
+
``hooks_constants.pre_tool_use_stdin.read_hook_input_dictionary_from_stdin``
|
|
5
|
+
rather than a hand-rolled ``json.load(sys.stdin)`` plus ``isinstance(dict)``
|
|
6
|
+
guard. The shared parser fails open on empty stdin, malformed JSON, and a
|
|
7
|
+
non-object JSON root by returning ``None``; the hand-rolled form these hooks
|
|
8
|
+
carried failed open on the same three cases by exiting zero. These tests drive
|
|
9
|
+
each real hook script through its production ``__main__`` stdin path over a
|
|
10
|
+
corpus that pins those fail-soft edges plus a representative allow payload, so a
|
|
11
|
+
conversion that changes any decision is caught.
|
|
12
|
+
|
|
13
|
+
The deterministic deny payloads for the two Write/Edit blockers whose triggers
|
|
14
|
+
need no filesystem or state setup (``md_to_html_blocker``,
|
|
15
|
+
``open_questions_in_plans_blocker``) are exercised here too; each remaining
|
|
16
|
+
hook's full deny coverage stays in its own suite, which also drives the real
|
|
17
|
+
``main()`` and so re-proves the decision after the parser swap.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
29
|
+
|
|
30
|
+
ALL_CONVERTED_HOOK_FILENAMES = (
|
|
31
|
+
"md_to_html_blocker.py",
|
|
32
|
+
"open_questions_in_plans_blocker.py",
|
|
33
|
+
"claude_md_orphan_file_blocker.py",
|
|
34
|
+
"pr_converge_bugteam_enforcer.py",
|
|
35
|
+
"verdict_directory_write_blocker.py",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
EMPTY_STDIN_PAYLOAD = ""
|
|
39
|
+
WHITESPACE_STDIN_PAYLOAD = " \n\t "
|
|
40
|
+
MALFORMED_JSON_PAYLOAD = "{not valid json"
|
|
41
|
+
NON_OBJECT_JSON_ARRAY_PAYLOAD = "[1, 2, 3]"
|
|
42
|
+
NON_OBJECT_JSON_SCALAR_PAYLOAD = "42"
|
|
43
|
+
|
|
44
|
+
ALL_FAIL_SOFT_PAYLOADS = (
|
|
45
|
+
EMPTY_STDIN_PAYLOAD,
|
|
46
|
+
WHITESPACE_STDIN_PAYLOAD,
|
|
47
|
+
MALFORMED_JSON_PAYLOAD,
|
|
48
|
+
NON_OBJECT_JSON_ARRAY_PAYLOAD,
|
|
49
|
+
NON_OBJECT_JSON_SCALAR_PAYLOAD,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_hook_script(hook_filename: str, stdin_text: str) -> subprocess.CompletedProcess:
|
|
54
|
+
hook_script_path = _BLOCKING_DIRECTORY / hook_filename
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
[sys.executable, str(hook_script_path)],
|
|
57
|
+
input=stdin_text,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
check=False,
|
|
61
|
+
cwd=str(Path.home()),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decision_from_stdout(completed: subprocess.CompletedProcess) -> str | None:
|
|
66
|
+
if not completed.stdout.strip():
|
|
67
|
+
return None
|
|
68
|
+
parsed_output = json.loads(completed.stdout)
|
|
69
|
+
return parsed_output["hookSpecificOutput"]["permissionDecision"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("hook_filename", ALL_CONVERTED_HOOK_FILENAMES)
|
|
73
|
+
@pytest.mark.parametrize("stdin_text", ALL_FAIL_SOFT_PAYLOADS)
|
|
74
|
+
def test_fail_soft_payload_exits_zero_with_no_decision(hook_filename: str, stdin_text: str) -> None:
|
|
75
|
+
completed = _run_hook_script(hook_filename, stdin_text)
|
|
76
|
+
assert completed.returncode == 0, (
|
|
77
|
+
f"{hook_filename} must exit zero on fail-soft stdin; "
|
|
78
|
+
f"got code {completed.returncode}, stderr {completed.stderr!r}"
|
|
79
|
+
)
|
|
80
|
+
assert _decision_from_stdout(completed) is None, (
|
|
81
|
+
f"{hook_filename} must emit no decision on fail-soft stdin; got stdout {completed.stdout!r}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_md_to_html_blocker_still_denies_relative_markdown_write() -> None:
|
|
86
|
+
payload = json.dumps(
|
|
87
|
+
{
|
|
88
|
+
"tool_name": "Write",
|
|
89
|
+
"tool_input": {"file_path": "notes/topic.md", "content": "# Topic"},
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
completed = _run_hook_script("md_to_html_blocker.py", payload)
|
|
93
|
+
assert completed.returncode == 0
|
|
94
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_md_to_html_blocker_still_allows_non_markdown_write() -> None:
|
|
98
|
+
payload = json.dumps(
|
|
99
|
+
{
|
|
100
|
+
"tool_name": "Write",
|
|
101
|
+
"tool_input": {"file_path": "notes/topic.txt", "content": "plain"},
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
completed = _run_hook_script("md_to_html_blocker.py", payload)
|
|
105
|
+
assert completed.returncode == 0
|
|
106
|
+
assert _decision_from_stdout(completed) is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_open_questions_blocker_still_denies_plan_with_open_questions(
|
|
110
|
+
tmp_path: Path,
|
|
111
|
+
) -> None:
|
|
112
|
+
plan_directory = tmp_path / "docs" / "plans"
|
|
113
|
+
plan_directory.mkdir(parents=True)
|
|
114
|
+
plan_path = plan_directory / "feature.md"
|
|
115
|
+
plan_body = "# Feature Plan\n\n## Open Questions\n\n- What endpoint do we call?\n"
|
|
116
|
+
payload = json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"tool_name": "Write",
|
|
119
|
+
"tool_input": {"file_path": str(plan_path), "content": plan_body},
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
|
|
123
|
+
assert completed.returncode == 0
|
|
124
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_open_questions_blocker_still_allows_plan_without_open_questions(
|
|
128
|
+
tmp_path: Path,
|
|
129
|
+
) -> None:
|
|
130
|
+
plan_directory = tmp_path / "docs" / "plans"
|
|
131
|
+
plan_directory.mkdir(parents=True)
|
|
132
|
+
plan_path = plan_directory / "feature.md"
|
|
133
|
+
plan_body = "# Feature Plan\n\n## Approach\n\nBuild the thing.\n"
|
|
134
|
+
payload = json.dumps(
|
|
135
|
+
{
|
|
136
|
+
"tool_name": "Write",
|
|
137
|
+
"tool_input": {"file_path": str(plan_path), "content": plan_body},
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
|
|
141
|
+
assert completed.returncode == 0
|
|
142
|
+
assert _decision_from_stdout(completed) is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_converted_hooks_allow_unrelated_tool_name() -> None:
|
|
146
|
+
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
|
147
|
+
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|
|
148
|
+
completed = _run_hook_script(each_hook_filename, payload)
|
|
149
|
+
assert completed.returncode == 0, (
|
|
150
|
+
f"{each_hook_filename} must exit zero on an unrelated tool; stderr {completed.stderr!r}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_every_converted_hook_imports_shared_parser() -> None:
|
|
155
|
+
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|
|
156
|
+
hook_source = (_BLOCKING_DIRECTORY / each_hook_filename).read_text(encoding="utf-8")
|
|
157
|
+
assert "read_hook_input_dictionary_from_stdin" in hook_source, (
|
|
158
|
+
f"{each_hook_filename} must read stdin through the shared parser"
|
|
159
|
+
)
|
|
160
|
+
assert "json.load(sys.stdin)" not in hook_source, (
|
|
161
|
+
f"{each_hook_filename} must not hand-roll json.load(sys.stdin)"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_blocking_directory_is_resolvable() -> None:
|
|
166
|
+
assert os.path.isdir(_BLOCKING_DIRECTORY)
|
|
@@ -37,7 +37,11 @@ blocking_directory = str(Path(__file__).resolve().parent)
|
|
|
37
37
|
if blocking_directory not in sys.path:
|
|
38
38
|
sys.path.insert(0, blocking_directory)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
41
|
+
if hooks_directory not in sys.path:
|
|
42
|
+
sys.path.insert(0, hooks_directory)
|
|
43
|
+
|
|
44
|
+
from config.verified_commit_constants import ( # noqa: E402
|
|
41
45
|
ALL_GATED_TOOL_NAMES,
|
|
42
46
|
ALL_VERDICT_PATH_SEGMENT_BODIES,
|
|
43
47
|
ALL_VERDICT_PATH_SEGMENT_NAMES,
|
|
@@ -61,11 +65,11 @@ from config.verified_commit_constants import (
|
|
|
61
65
|
NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
|
|
62
66
|
PATH_OBFUSCATION_PRIMITIVE_PATTERN,
|
|
63
67
|
RELATIVE_VERDICT_DIRECTORY_PATTERN,
|
|
68
|
+
VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
|
|
64
69
|
VERDICT_DIRECTORY_GUARD_MESSAGE,
|
|
65
70
|
VERDICT_DIRECTORY_NAME,
|
|
66
71
|
VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN,
|
|
67
72
|
VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
|
|
68
|
-
VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
|
|
69
73
|
VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
|
|
70
74
|
VERDICT_FILE_RELATIVE_REFERENCE_PATTERN,
|
|
71
75
|
VERDICT_PATH_GLUE_PATTERN,
|
|
@@ -74,6 +78,10 @@ from config.verified_commit_constants import (
|
|
|
74
78
|
WRITE_CALL_REGION_PATTERN,
|
|
75
79
|
)
|
|
76
80
|
|
|
81
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
82
|
+
read_hook_input_dictionary_from_stdin,
|
|
83
|
+
)
|
|
84
|
+
|
|
77
85
|
|
|
78
86
|
def _directory_change_verbs_pattern() -> str:
|
|
79
87
|
"""Build the alternation of directory-change verbs for a change matcher.
|
|
@@ -650,11 +658,8 @@ def decision_for_payload(pretooluse_payload: dict) -> dict | None:
|
|
|
650
658
|
|
|
651
659
|
def main() -> None:
|
|
652
660
|
"""Read the PreToolUse payload and deny verdict-directory shell access."""
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
except json.JSONDecodeError:
|
|
656
|
-
return
|
|
657
|
-
if not isinstance(pretooluse_payload, dict):
|
|
661
|
+
pretooluse_payload = read_hook_input_dictionary_from_stdin()
|
|
662
|
+
if pretooluse_payload is None:
|
|
658
663
|
return
|
|
659
664
|
deny_decision = decision_for_payload(pretooluse_payload)
|
|
660
665
|
if deny_decision is None:
|
package/hooks/hooks.json
CHANGED
|
@@ -5,60 +5,10 @@
|
|
|
5
5
|
{
|
|
6
6
|
"matcher": "Write|Edit",
|
|
7
7
|
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
"type": "command",
|
|
10
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py",
|
|
11
|
-
"timeout": 10
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"type": "command",
|
|
15
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/sensitive_file_protector.py",
|
|
16
|
-
"timeout": 10
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
"type": "command",
|
|
20
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validation/hook_format_validator.py",
|
|
21
|
-
"timeout": 15
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"type": "command",
|
|
25
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
|
|
26
|
-
"timeout": 30
|
|
27
|
-
},
|
|
28
8
|
{
|
|
29
9
|
"type": "command",
|
|
30
10
|
"command": "python3 -c \"import sys; sys.path.insert(0, r'${CLAUDE_PLUGIN_ROOT}/hooks'); from validators.run_all_validators import main; sys.exit(main())\"",
|
|
31
11
|
"timeout": 15
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"type": "command",
|
|
35
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/tdd_enforcer.py",
|
|
36
|
-
"timeout": 10
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"type": "command",
|
|
40
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
41
|
-
"timeout": 10
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"type": "command",
|
|
45
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/state_description_blocker.py",
|
|
46
|
-
"timeout": 10
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"type": "command",
|
|
50
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/subprocess_budget_completeness.py",
|
|
51
|
-
"timeout": 10
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
"type": "command",
|
|
55
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hook_prose_detector_consistency.py",
|
|
56
|
-
"timeout": 10
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
"type": "command",
|
|
60
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_message_accuracy_blocker.py",
|
|
61
|
-
"timeout": 10
|
|
62
12
|
}
|
|
63
13
|
]
|
|
64
14
|
},
|
|
@@ -67,23 +17,8 @@
|
|
|
67
17
|
"hooks": [
|
|
68
18
|
{
|
|
69
19
|
"type": "command",
|
|
70
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/
|
|
71
|
-
"timeout":
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
"type": "command",
|
|
75
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/claude_md_orphan_file_blocker.py",
|
|
76
|
-
"timeout": 10
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
"type": "command",
|
|
80
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
|
|
81
|
-
"timeout": 10
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
"type": "command",
|
|
85
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
|
|
86
|
-
"timeout": 10
|
|
20
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py",
|
|
21
|
+
"timeout": 60
|
|
87
22
|
}
|
|
88
23
|
]
|
|
89
24
|
},
|
|
@@ -199,6 +134,11 @@
|
|
|
199
134
|
"type": "command",
|
|
200
135
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_converge_bugteam_enforcer.py",
|
|
201
136
|
"timeout": 10
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"type": "command",
|
|
140
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_verifier_spawn_preflight_gate.py",
|
|
141
|
+
"timeout": 30
|
|
202
142
|
}
|
|
203
143
|
]
|
|
204
144
|
},
|
|
@@ -324,18 +264,8 @@
|
|
|
324
264
|
"hooks": [
|
|
325
265
|
{
|
|
326
266
|
"type": "command",
|
|
327
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validation/
|
|
328
|
-
"timeout":
|
|
329
|
-
},
|
|
330
|
-
{
|
|
331
|
-
"type": "command",
|
|
332
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/auto_formatter.py",
|
|
333
|
-
"timeout": 30
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
"type": "command",
|
|
337
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/doc_gist_auto_publish.py ${CLAUDE_PLUGIN_ROOT}",
|
|
338
|
-
"timeout": 60
|
|
267
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validation/post_tool_use_dispatcher.py",
|
|
268
|
+
"timeout": 180
|
|
339
269
|
}
|
|
340
270
|
]
|
|
341
271
|
},
|
|
@@ -14,9 +14,10 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
14
14
|
| `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
|
|
15
15
|
| `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
|
|
16
16
|
| `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
|
|
17
|
+
| `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
|
|
17
18
|
| `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
|
|
18
19
|
| `dead_argparse_argument_constants.py` | Patterns for detecting unused argparse arguments |
|
|
19
|
-
| `dead_config_field_constants.py` | Patterns for detecting unused dataclass
|
|
20
|
+
| `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
|
|
20
21
|
| `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
|
|
21
22
|
| `dead_module_constant_constants.py` | Patterns for detecting unexported `UPPER_SNAKE` constants in `*_constants.py` modules |
|
|
22
23
|
| `destructive_command_segment_constants.py` | The list of destructive shell command patterns the blocker matches |
|
|
@@ -41,6 +42,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
41
42
|
| `pre_tool_use_stdin.py` | `read_hook_input_dictionary_from_stdin()` — shared stdin parser for PreToolUse hooks |
|
|
42
43
|
| `precommit_code_rules_gate_constants.py` | Scope argument and exit-code constants for the precommit gate |
|
|
43
44
|
| `project_paths_reader.py` | Loads `~/.claude/project-paths.json` — the per-user project-path registry |
|
|
45
|
+
| `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
|
|
44
46
|
| `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
|
|
45
47
|
| `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
|
|
46
48
|
| `setup_project_paths_constants.py` | Encoding policy, BOM marker, and registry meta-key used across multiple hooks |
|
|
@@ -32,6 +32,56 @@ DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
|
|
|
32
32
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
|
|
33
33
|
MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
|
|
34
34
|
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
|
|
35
|
+
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
|
|
36
|
+
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER: int = 2
|
|
37
|
+
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES: int = 5
|
|
38
|
+
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION: int = 2
|
|
39
|
+
MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
|
|
40
|
+
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
|
|
41
|
+
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
|
|
42
|
+
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
|
|
43
|
+
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS: frozenset[str] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"UPPER_SNAKE_CASE",
|
|
46
|
+
"SCREAMING_SNAKE_CASE",
|
|
47
|
+
"UPPER_CASE",
|
|
48
|
+
"SNAKE_CASE",
|
|
49
|
+
"CAMEL_CASE",
|
|
50
|
+
"PASCAL_CASE",
|
|
51
|
+
"KEBAB_CASE",
|
|
52
|
+
"TITLE_CASE",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS: frozenset[str] = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"rule",
|
|
58
|
+
"rules",
|
|
59
|
+
"doc",
|
|
60
|
+
"docs",
|
|
61
|
+
"document",
|
|
62
|
+
"file",
|
|
63
|
+
"env",
|
|
64
|
+
"environment",
|
|
65
|
+
"variable",
|
|
66
|
+
"set",
|
|
67
|
+
"reads",
|
|
68
|
+
"read",
|
|
69
|
+
"per",
|
|
70
|
+
"follows",
|
|
71
|
+
"following",
|
|
72
|
+
"see",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES: tuple[str, ...] = (
|
|
76
|
+
".md",
|
|
77
|
+
".py",
|
|
78
|
+
".txt",
|
|
79
|
+
".json",
|
|
80
|
+
)
|
|
81
|
+
DOCSTRING_REFERENCE_MARKER_WINDOW: int = 2
|
|
82
|
+
ALL_GENERIC_CHECK_NAME_TOKENS: frozenset[str] = frozenset(
|
|
83
|
+
{"check", "checks", "test", "tests", "in", "for", "and", "the"}
|
|
84
|
+
)
|
|
35
85
|
|
|
36
86
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
|
|
37
87
|
"no consumer reads",
|
|
@@ -44,6 +94,17 @@ ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
|
|
|
44
94
|
"not yet read by any consumer",
|
|
45
95
|
)
|
|
46
96
|
|
|
97
|
+
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES: int = 3
|
|
98
|
+
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES: tuple[str, ...] = (
|
|
99
|
+
"no literals appear inline",
|
|
100
|
+
"no literal appears inline",
|
|
101
|
+
"no literals inline",
|
|
102
|
+
"no inline literals",
|
|
103
|
+
"no string literals appear inline",
|
|
104
|
+
"without any inline literals",
|
|
105
|
+
"no hardcoded literals remain",
|
|
106
|
+
)
|
|
107
|
+
|
|
47
108
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES: tuple[str, ...] = (
|
|
48
109
|
"only when",
|
|
49
110
|
"only if",
|
|
@@ -17,6 +17,10 @@ ALL_JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
|
17
17
|
ALL_CODE_EXTENSIONS = ALL_PYTHON_EXTENSIONS | ALL_JAVASCRIPT_EXTENSIONS
|
|
18
18
|
|
|
19
19
|
ALL_TEST_PATH_PATTERNS = {"test_", "_test.", ".test.", ".spec.", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
|
|
20
|
+
STRICT_TEST_FILE_BASENAME_PATTERN: re.Pattern[str] = re.compile(
|
|
21
|
+
r"^(test_.*|.*_test|.*\.test|.*\.spec)\.[^.]+$|^conftest\.py$"
|
|
22
|
+
)
|
|
23
|
+
ALL_STRICT_TEST_DIRECTORY_SEGMENTS: tuple[str, ...] = ("/tests/",)
|
|
20
24
|
ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES: tuple[str, str] = ("/tmp", "/temp")
|
|
21
25
|
CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME: str = "CLAUDE_JOB_DIR"
|
|
22
26
|
CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY: str = "tmp"
|
|
@@ -38,6 +42,8 @@ UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
|
38
42
|
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
|
|
39
43
|
|
|
40
44
|
DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
|
|
45
|
+
INLINE_CODE_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"``?(\.?[A-Za-z_][A-Za-z0-9_.]*)``?")
|
|
46
|
+
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN: re.Pattern[str] = re.compile(r"^\.?[A-Za-z_][A-Za-z0-9_]*$")
|
|
41
47
|
ALL_DOCSTRING_ARGS_SECTION_HEADERS: tuple[str, ...] = ("Args:", "Arguments:")
|
|
42
48
|
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS: frozenset[str] = frozenset({
|
|
43
49
|
"Returns:",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Configuration constants for the code_verifier_spawn_preflight_gate hook.
|
|
2
|
+
|
|
3
|
+
The gate denies an ``Agent`` spawn whose ``subagent_type`` is ``code-verifier``
|
|
4
|
+
when the branch carries a merge conflict against its base ref or a CODE_RULES
|
|
5
|
+
violation on a line added in the uncommitted working tree. It runs two
|
|
6
|
+
pre-flight checks before the expensive verification spawn and addresses its
|
|
7
|
+
deny reason to the spawning agent so that agent fixes the named issues and
|
|
8
|
+
re-spawns. Every literal the hook body reads lives here; the hook imports
|
|
9
|
+
``AGENT_TOOL_NAME`` from ``pr_converge_bugteam_enforcer_constants`` rather than
|
|
10
|
+
redefining it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CODE_VERIFIER_SUBAGENT_TYPE: str = "code-verifier"
|
|
18
|
+
|
|
19
|
+
ALL_MERGE_TREE_COMMAND_FLAGS: tuple[str, ...] = (
|
|
20
|
+
"merge-tree",
|
|
21
|
+
"--write-tree",
|
|
22
|
+
"--name-only",
|
|
23
|
+
)
|
|
24
|
+
MERGE_TREE_CONFLICT_EXIT_CODE: int = 1
|
|
25
|
+
MERGE_TREE_CLEAN_EXIT_CODE: int = 0
|
|
26
|
+
MERGE_TREE_TIMEOUT_SECONDS: int = 30
|
|
27
|
+
|
|
28
|
+
ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS: tuple[str, ...] = (
|
|
29
|
+
"-c",
|
|
30
|
+
"core.quotePath=false",
|
|
31
|
+
"diff",
|
|
32
|
+
"--name-only",
|
|
33
|
+
"--no-renames",
|
|
34
|
+
)
|
|
35
|
+
ALL_UNIFIED_ZERO_DIFF_FLAGS: tuple[str, ...] = ("diff", "--unified=0")
|
|
36
|
+
|
|
37
|
+
DENY_REASON_LEAD: str = (
|
|
38
|
+
"BLOCKED [code-verifier-spawn-preflight]: an Agent spawn with subagent_type "
|
|
39
|
+
"code-verifier is blocked because the branch is not in a committable state. "
|
|
40
|
+
"Fix these, then re-spawn the code-verifier:"
|
|
41
|
+
)
|
|
42
|
+
MERGE_CONFLICT_SECTION_HEADER: str = "Merge conflicts vs {base_ref}:"
|
|
43
|
+
CODE_RULES_SECTION_HEADER: str = "CODE_RULES violations on changed lines:"
|
|
44
|
+
|
|
45
|
+
GATE_SCRIPTS_RELATIVE_PATH: Path = Path("_shared") / "pr-loop" / "scripts"
|
|
@@ -16,18 +16,18 @@ from hooks_constants.dead_module_constant_constants import (
|
|
|
16
16
|
PYTHON_SOURCE_SUFFIX,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
ALL_CONFIG_CLASS_NAME_SUFFIXES: tuple[str, ...] = ("Config", "Selectors")
|
|
20
20
|
DATACLASSES_MODULE_NAME: str = "dataclasses"
|
|
21
21
|
MAX_DEAD_CONFIG_FIELD_ISSUES: int = 25
|
|
22
22
|
DEAD_CONFIG_FIELD_GUIDANCE: str = (
|
|
23
|
-
"config dataclass field is defined but read by no production
|
|
24
|
-
" enclosing package tree - remove the dead field, or read it
|
|
25
|
-
" is needed (CODE_RULES §9.8)"
|
|
23
|
+
"config or selectors dataclass field is defined but read by no production"
|
|
24
|
+
" module in the enclosing package tree - remove the dead field, or read it"
|
|
25
|
+
" where the value is needed (CODE_RULES §9.8)"
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
|
+
"ALL_CONFIG_CLASS_NAME_SUFFIXES",
|
|
29
30
|
"ALL_REFLECTIVE_FIELD_CONSUMER_NAMES",
|
|
30
|
-
"CONFIG_CLASS_NAME_SUFFIX",
|
|
31
31
|
"CONFIG_DIRECTORY_SEGMENT",
|
|
32
32
|
"DATACLASSES_MODULE_NAME",
|
|
33
33
|
"DEAD_CONFIG_FIELD_GUIDANCE",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Cache paths and tunables for the mypy_validator per-session caches.
|
|
2
|
+
|
|
3
|
+
The validator keeps two per-session caches so a Write/Edit burst under one
|
|
4
|
+
project root does not repeat work whose result has not changed: a config-walk
|
|
5
|
+
cache keyed by the target file's directory, and a content-hash cache keyed by
|
|
6
|
+
target file.
|
|
7
|
+
Both live as JSON files under the per-session hook-state cache directory the
|
|
8
|
+
live tree already uses for hook state. A cold or missing cache simply does the
|
|
9
|
+
work, so these paths are safe to miss.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"HOOK_STATE_CACHE_DIRECTORY",
|
|
18
|
+
"MYPY_CONFIG_CACHE_FILENAME",
|
|
19
|
+
"MYPY_CONTENT_HASH_CACHE_FILENAME",
|
|
20
|
+
"SESSION_ID_ENVIRONMENT_VARIABLE",
|
|
21
|
+
"UNKNOWN_SESSION_IDENTIFIER",
|
|
22
|
+
"CONTENT_HASH_CACHE_PASSING_EXIT_CODE",
|
|
23
|
+
"CACHE_FILE_ENCODING",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
HOOK_STATE_CACHE_DIRECTORY = os.path.join(os.path.expanduser("~"), ".claude", "cache")
|
|
27
|
+
|
|
28
|
+
MYPY_CONFIG_CACHE_FILENAME = "mypy-validator-config-cache.json"
|
|
29
|
+
MYPY_CONTENT_HASH_CACHE_FILENAME = "mypy-validator-content-hash-cache.json"
|
|
30
|
+
|
|
31
|
+
SESSION_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_SESSION_ID"
|
|
32
|
+
UNKNOWN_SESSION_IDENTIFIER = "unknown-session"
|
|
33
|
+
|
|
34
|
+
CONTENT_HASH_CACHE_PASSING_EXIT_CODE = 0
|
|
35
|
+
|
|
36
|
+
CACHE_FILE_ENCODING = "utf-8"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Constants for the PostToolUse dispatcher that hosts the after-write hooks.
|
|
2
|
+
|
|
3
|
+
Holds the ordered hosted-hook list with each hook's extra command-line
|
|
4
|
+
arguments and blocking flag, the PostToolUse block-decision string and key,
|
|
5
|
+
and the hook-event name. The dispatcher imports these; no literals appear
|
|
6
|
+
inline in the dispatcher script.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BLOCK_DECISION",
|
|
15
|
+
"DECISION_KEY",
|
|
16
|
+
"REASON_KEY",
|
|
17
|
+
"HOOK_EVENT_NAME",
|
|
18
|
+
"EMPTY_REASON_BLOCK_FALLBACK",
|
|
19
|
+
"PLUGIN_ROOT_PLACEHOLDER",
|
|
20
|
+
"PostHostedHookEntry",
|
|
21
|
+
"ALL_POST_HOSTED_HOOK_ENTRIES",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
BLOCK_DECISION = "block"
|
|
25
|
+
DECISION_KEY = "decision"
|
|
26
|
+
REASON_KEY = "reason"
|
|
27
|
+
HOOK_EVENT_NAME = "PostToolUse"
|
|
28
|
+
EMPTY_REASON_BLOCK_FALLBACK = "[dispatcher] hook blocked with no reason — write blocked"
|
|
29
|
+
|
|
30
|
+
PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class PostHostedHookEntry:
|
|
35
|
+
"""A single hosted PostToolUse hook with its run-time arguments and flags.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
script_relative_path: Hook path relative to the hooks/ directory.
|
|
39
|
+
extra_argument_relative_paths: Command-line arguments the live entry
|
|
40
|
+
passes after the script path, each a path relative to the plugin
|
|
41
|
+
root (the hooks/ parent). The dispatcher resolves each to an
|
|
42
|
+
absolute path and exposes them as the hook's argv tail, so a hook
|
|
43
|
+
that reads sys.argv[1] resolves the same path the live entry gives
|
|
44
|
+
it. An empty tuple means the live entry passes no extra arguments.
|
|
45
|
+
is_blocking: True when this hook can emit a block decision and a crash
|
|
46
|
+
should surface a blocking signal; False when the hook only performs
|
|
47
|
+
a side effect and never blocks.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
script_relative_path: str
|
|
51
|
+
extra_argument_relative_paths: tuple[str, ...] = field(default_factory=tuple)
|
|
52
|
+
is_blocking: bool = field(default=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
ALL_POST_HOSTED_HOOK_ENTRIES: tuple[PostHostedHookEntry, ...] = (
|
|
56
|
+
PostHostedHookEntry(
|
|
57
|
+
script_relative_path="validation/mypy_validator.py",
|
|
58
|
+
is_blocking=True,
|
|
59
|
+
),
|
|
60
|
+
PostHostedHookEntry(
|
|
61
|
+
script_relative_path="workflow/auto_formatter.py",
|
|
62
|
+
is_blocking=False,
|
|
63
|
+
),
|
|
64
|
+
PostHostedHookEntry(
|
|
65
|
+
script_relative_path="workflow/doc_gist_auto_publish.py",
|
|
66
|
+
extra_argument_relative_paths=(PLUGIN_ROOT_PLACEHOLDER,),
|
|
67
|
+
is_blocking=False,
|
|
68
|
+
),
|
|
69
|
+
)
|