claude-dev-env 1.23.1 → 1.25.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/docs/CODE_RULES.md +14 -1
- package/hooks/blocking/_gh_body_arg_utils.py +171 -13
- package/hooks/blocking/code-rules-enforcer.py +490 -15
- package/hooks/blocking/gh-body-arg-blocker.py +27 -21
- package/hooks/blocking/pr-description-enforcer.py +247 -11
- package/hooks/blocking/tdd-enforcer.py +208 -13
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
- package/hooks/blocking/test_pr_description_enforcer.py +193 -3
- package/hooks/blocking/test_tdd_enforcer.py +249 -0
- package/hooks/validators/exempt_paths.py +99 -0
- package/hooks/validators/magic_value_checks.py +126 -26
- package/hooks/validators/test_magic_value_checks.py +356 -2
- package/package.json +1 -1
- package/rules/gh-body-file.md +11 -2
- package/skills/bugteam/SKILL.md +111 -59
- package/skills/searching-obsidian-vault/SKILL.md +131 -0
|
@@ -14,19 +14,20 @@ logical line, then use shlex.split(..., posix=False) on that line so '--body'
|
|
|
14
14
|
appearing inside a quoted flag value or in heredoc body content on non-continuation
|
|
15
15
|
lines does not trigger a false positive. Both '--body value' and '--body=value' forms are blocked,
|
|
16
16
|
as are their short '-b' equivalents. '--body-file' and '--body-file=...' are allowed.
|
|
17
|
-
|
|
17
|
+
Fails CLOSED on shlex ValueError when the logical line matches an affected
|
|
18
|
+
subcommand AND contains a bare '--body'/'-b' literal (exactly the heredoc
|
|
19
|
+
pattern this hook must catch); otherwise approves unparseable input.
|
|
18
20
|
"""
|
|
19
21
|
|
|
20
22
|
import json
|
|
21
23
|
import re
|
|
22
|
-
import shlex
|
|
23
24
|
import sys
|
|
24
25
|
|
|
25
26
|
from _gh_body_arg_utils import (
|
|
26
27
|
all_body_flags,
|
|
27
28
|
all_body_flag_prefixes,
|
|
28
|
-
all_value_flags,
|
|
29
29
|
get_logical_first_line,
|
|
30
|
+
iter_significant_tokens,
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
_GH_BODY_SUBCOMMANDS = re.compile(
|
|
@@ -37,6 +38,10 @@ _GH_BODY_SUBCOMMANDS = re.compile(
|
|
|
37
38
|
re.IGNORECASE,
|
|
38
39
|
)
|
|
39
40
|
|
|
41
|
+
_BARE_BODY_TOKEN_PATTERN = re.compile(
|
|
42
|
+
r"(?<!\S)(?:--body|-b)(?:=|(?![-\w]))",
|
|
43
|
+
)
|
|
44
|
+
|
|
40
45
|
_BASH_TOOL_NAME = "Bash"
|
|
41
46
|
|
|
42
47
|
_CORRECTIVE_MESSAGE = (
|
|
@@ -49,16 +54,21 @@ _CORRECTIVE_MESSAGE = (
|
|
|
49
54
|
" f.write(body_text)\n"
|
|
50
55
|
" body_path = f.name\n"
|
|
51
56
|
" # then: gh ... --body-file body_path\n\n"
|
|
52
|
-
"Safe PowerShell pattern:\n"
|
|
57
|
+
"Safe PowerShell pattern (BOM-free, works on 5.1 and 7+):\n"
|
|
53
58
|
" $bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')\n"
|
|
54
|
-
" @'\n"
|
|
59
|
+
" $body = @'\n"
|
|
55
60
|
" <your markdown body>\n"
|
|
56
|
-
" '
|
|
61
|
+
" '@\n"
|
|
62
|
+
" [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))\n"
|
|
57
63
|
" gh ... --body-file $bodyPath\n\n"
|
|
58
64
|
"See ~/.claude/rules/gh-body-file.md for full guidance."
|
|
59
65
|
)
|
|
60
66
|
|
|
61
67
|
|
|
68
|
+
def _logical_line_has_bare_body_token(logical_line: str) -> bool:
|
|
69
|
+
return bool(_BARE_BODY_TOKEN_PATTERN.search(logical_line))
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
def _uses_body_string_arg(command: str) -> bool:
|
|
63
73
|
"""Return True if command calls an affected gh subcommand with --body <string>.
|
|
64
74
|
|
|
@@ -68,26 +78,22 @@ def _uses_body_string_arg(command: str) -> bool:
|
|
|
68
78
|
and quoted values retain their surrounding quotes as part of the token, so
|
|
69
79
|
'--body' embedded in a quoted value cannot be mistaken for a standalone flag.
|
|
70
80
|
Detects both '--body value'/'--body=value' forms and their short '-b'
|
|
71
|
-
equivalents.
|
|
81
|
+
equivalents. Fails CLOSED on shlex ValueError when the logical line matches
|
|
82
|
+
an affected subcommand and contains a bare --body / -b token literal, since
|
|
83
|
+
heredoc-wrapped --body arguments are exactly the pattern this hook exists
|
|
84
|
+
to block; otherwise approves unparseable input (out of scope).
|
|
72
85
|
"""
|
|
73
|
-
|
|
74
|
-
if not _GH_BODY_SUBCOMMANDS.search(logical_line):
|
|
86
|
+
if not _GH_BODY_SUBCOMMANDS.search(get_logical_first_line(command)):
|
|
75
87
|
return False
|
|
76
88
|
try:
|
|
77
|
-
|
|
89
|
+
significant_tokens = list(iter_significant_tokens(command))
|
|
78
90
|
except ValueError:
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
continue
|
|
85
|
-
if each_token in all_body_flags or any(
|
|
86
|
-
each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes
|
|
87
|
-
):
|
|
91
|
+
return _logical_line_has_bare_body_token(get_logical_first_line(command))
|
|
92
|
+
for each_token, _remaining_tokens in significant_tokens:
|
|
93
|
+
if each_token in all_body_flags:
|
|
94
|
+
return True
|
|
95
|
+
if any(each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes):
|
|
88
96
|
return True
|
|
89
|
-
if each_token in all_value_flags:
|
|
90
|
-
should_skip_next_token = True
|
|
91
97
|
return False
|
|
92
98
|
|
|
93
99
|
|
|
@@ -2,6 +2,23 @@ import json
|
|
|
2
2
|
import os
|
|
3
3
|
import re
|
|
4
4
|
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import shlex
|
|
8
|
+
|
|
9
|
+
from _gh_body_arg_utils import (
|
|
10
|
+
all_body_flag_prefixes,
|
|
11
|
+
all_body_flags,
|
|
12
|
+
all_value_flag_equals_prefixes,
|
|
13
|
+
all_value_flags,
|
|
14
|
+
body_file_flag,
|
|
15
|
+
body_file_flag_prefix,
|
|
16
|
+
body_file_short_flag,
|
|
17
|
+
body_file_short_flag_prefix,
|
|
18
|
+
count_extra_tokens_to_skip_for_split_quoted_value,
|
|
19
|
+
get_logical_first_line,
|
|
20
|
+
iter_significant_tokens,
|
|
21
|
+
)
|
|
5
22
|
|
|
6
23
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
7
24
|
PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
|
|
@@ -20,20 +37,236 @@ VAGUE_LANGUAGE_PATTERN = re.compile(
|
|
|
20
37
|
)
|
|
21
38
|
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
shell_variable_sigil: str = "$"
|
|
41
|
+
body_file_stdin_sentinel: str = "-"
|
|
42
|
+
all_quote_characters: frozenset[str] = frozenset({'"', "'"})
|
|
43
|
+
file_encoding_utf8: str = "utf-8"
|
|
44
|
+
|
|
45
|
+
_non_body_value_flags: frozenset[str] = all_value_flags - {body_file_flag, body_file_short_flag}
|
|
46
|
+
|
|
47
|
+
_non_body_value_flag_equals_prefixes: tuple[str, ...] = tuple(
|
|
48
|
+
sorted(
|
|
49
|
+
(
|
|
50
|
+
prefix for prefix in all_value_flag_equals_prefixes
|
|
51
|
+
if not prefix.startswith("--body") and not prefix.startswith("-b=")
|
|
52
|
+
),
|
|
53
|
+
key=len,
|
|
54
|
+
reverse=True,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PathTraversalError(Exception):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _is_flag_shaped_token(token: str) -> bool:
|
|
63
|
+
if len(token) < 2:
|
|
64
|
+
return False
|
|
65
|
+
if not token.startswith("-"):
|
|
66
|
+
return False
|
|
67
|
+
return token[1] == "-" or token[1].isalpha()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _strip_surrounding_quotes(token: str) -> str:
|
|
71
|
+
if len(token) < 2:
|
|
72
|
+
return token
|
|
73
|
+
first_character = token[0]
|
|
74
|
+
last_character = token[-1]
|
|
75
|
+
if first_character in all_quote_characters and first_character == last_character:
|
|
76
|
+
return token[1:-1]
|
|
77
|
+
return token
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_unresolvable_shell_value(token: str) -> bool:
|
|
81
|
+
return token.startswith(shell_variable_sigil)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_body_file_contents(file_path: str) -> str | None:
|
|
85
|
+
given_path = Path(file_path)
|
|
86
|
+
allowed_root = Path.cwd().resolve()
|
|
87
|
+
if given_path.is_symlink():
|
|
88
|
+
resolved_target = given_path.resolve()
|
|
89
|
+
try:
|
|
90
|
+
resolved_target.relative_to(allowed_root)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise PathTraversalError("symlink target resolves outside allowed root")
|
|
93
|
+
resolved_path = given_path.resolve()
|
|
94
|
+
if not given_path.is_absolute():
|
|
95
|
+
try:
|
|
96
|
+
resolved_path.relative_to(allowed_root)
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise PathTraversalError("relative path resolves outside allowed root")
|
|
99
|
+
try:
|
|
100
|
+
with open(resolved_path, "r", encoding=file_encoding_utf8, errors="replace") as body_file:
|
|
101
|
+
return body_file.read()
|
|
102
|
+
except (FileNotFoundError, IsADirectoryError, PermissionError, OSError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolve_body_file_value(raw_value_token: str) -> str | None:
|
|
107
|
+
"""Return file contents, or None when the body cannot be audited.
|
|
108
|
+
|
|
109
|
+
None means body is present but unauditable -- skip enforcement.
|
|
110
|
+
This covers: stdin sentinel, unresolvable shell variables, and path-traversal-rejected paths.
|
|
111
|
+
"""
|
|
112
|
+
stripped_value = _strip_surrounding_quotes(raw_value_token)
|
|
113
|
+
if not stripped_value:
|
|
114
|
+
return None
|
|
115
|
+
if stripped_value == body_file_stdin_sentinel:
|
|
116
|
+
return None
|
|
117
|
+
if _is_unresolvable_shell_value(stripped_value):
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return _read_body_file_contents(stripped_value)
|
|
121
|
+
except PathTraversalError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_body_string_value(raw_value_token: str) -> str:
|
|
126
|
+
stripped_value = _strip_surrounding_quotes(raw_value_token)
|
|
127
|
+
if _is_unresolvable_shell_value(stripped_value):
|
|
128
|
+
return ""
|
|
129
|
+
return stripped_value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _reassemble_split_quoted_value(first_value_token: str, remaining_tokens: list[str]) -> str | None:
|
|
133
|
+
extra_tokens_consumed = count_extra_tokens_to_skip_for_split_quoted_value(
|
|
134
|
+
remaining_tokens,
|
|
135
|
+
first_value_token,
|
|
136
|
+
)
|
|
137
|
+
if extra_tokens_consumed is None:
|
|
138
|
+
return None
|
|
139
|
+
if extra_tokens_consumed == 0:
|
|
140
|
+
return first_value_token
|
|
141
|
+
continuation_tokens = remaining_tokens[:extra_tokens_consumed]
|
|
142
|
+
return " ".join([first_value_token, *continuation_tokens])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _match_body_flag_equals_prefix(token: str) -> str | None:
|
|
146
|
+
for each_prefix in all_body_flag_prefixes:
|
|
147
|
+
if token.startswith(each_prefix):
|
|
148
|
+
return each_prefix
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _match_body_file_equals_prefix(token: str) -> str | None:
|
|
153
|
+
for each_prefix in (body_file_flag_prefix, body_file_short_flag_prefix):
|
|
154
|
+
if token.startswith(each_prefix):
|
|
155
|
+
return each_prefix
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _match_non_body_value_flag_equals_prefix(token: str) -> str | None:
|
|
160
|
+
for each_prefix in _non_body_value_flag_equals_prefixes:
|
|
161
|
+
if token.startswith(each_prefix):
|
|
162
|
+
return each_prefix
|
|
163
|
+
return None
|
|
27
164
|
|
|
28
|
-
body_match = re.search(r'--body\s+"([^"]*)"', command) or re.search(r"--body\s+'([^']*)'", command)
|
|
29
|
-
if body_match:
|
|
30
|
-
return body_match.group(1)
|
|
31
165
|
|
|
32
|
-
|
|
33
|
-
if
|
|
34
|
-
return short_flag_match.group(1)
|
|
166
|
+
def _scan_raw_tokens_for_body(all_raw_tokens: list[str]) -> str | None | bool:
|
|
167
|
+
"""Return the body value from a raw token list, or False if no body flag found.
|
|
35
168
|
|
|
36
|
-
|
|
169
|
+
Returns False when no body/body-file flag is present (caller should continue).
|
|
170
|
+
Returns None when a body-file flag is present but malformed (no value follows).
|
|
171
|
+
Returns str for body string values (may be empty for shell vars/sentinels).
|
|
172
|
+
"""
|
|
173
|
+
token_index = 0
|
|
174
|
+
while token_index < len(all_raw_tokens):
|
|
175
|
+
current_token = all_raw_tokens[token_index]
|
|
176
|
+
remaining_raw = all_raw_tokens[token_index + 1:]
|
|
177
|
+
non_body_equals_prefix = _match_non_body_value_flag_equals_prefix(current_token)
|
|
178
|
+
if non_body_equals_prefix is not None:
|
|
179
|
+
first_value_token = current_token[len(non_body_equals_prefix):]
|
|
180
|
+
extra_skip = count_extra_tokens_to_skip_for_split_quoted_value(remaining_raw, first_value_token)
|
|
181
|
+
token_index += 1 + (extra_skip or 0)
|
|
182
|
+
continue
|
|
183
|
+
if current_token in _non_body_value_flags:
|
|
184
|
+
if remaining_raw and not _is_flag_shaped_token(remaining_raw[0]):
|
|
185
|
+
first_value_token = remaining_raw[0]
|
|
186
|
+
extra_skip = count_extra_tokens_to_skip_for_split_quoted_value(remaining_raw[1:], first_value_token)
|
|
187
|
+
token_index += 1 + 1 + (extra_skip or 0)
|
|
188
|
+
continue
|
|
189
|
+
token_index += 1
|
|
190
|
+
continue
|
|
191
|
+
body_equals_prefix = _match_body_flag_equals_prefix(current_token)
|
|
192
|
+
if body_equals_prefix is not None:
|
|
193
|
+
first_value_token = current_token[len(body_equals_prefix):]
|
|
194
|
+
full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw)
|
|
195
|
+
if full_value_token is None:
|
|
196
|
+
return None
|
|
197
|
+
return _resolve_body_string_value(full_value_token)
|
|
198
|
+
body_file_equals_prefix = _match_body_file_equals_prefix(current_token)
|
|
199
|
+
if body_file_equals_prefix is not None:
|
|
200
|
+
first_value_token = current_token[len(body_file_equals_prefix):]
|
|
201
|
+
full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw)
|
|
202
|
+
if full_value_token is None:
|
|
203
|
+
return None
|
|
204
|
+
return _resolve_body_file_value(full_value_token)
|
|
205
|
+
if current_token in all_body_flags:
|
|
206
|
+
if not remaining_raw or _is_flag_shaped_token(remaining_raw[0]):
|
|
207
|
+
return None
|
|
208
|
+
first_value_token = remaining_raw[0]
|
|
209
|
+
full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw[1:])
|
|
210
|
+
if full_value_token is None:
|
|
211
|
+
return None
|
|
212
|
+
return _resolve_body_string_value(full_value_token)
|
|
213
|
+
if current_token in {body_file_flag, body_file_short_flag}:
|
|
214
|
+
if not remaining_raw or _is_flag_shaped_token(remaining_raw[0]):
|
|
215
|
+
return None
|
|
216
|
+
first_value_token = remaining_raw[0]
|
|
217
|
+
full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw[1:])
|
|
218
|
+
if full_value_token is None:
|
|
219
|
+
return None
|
|
220
|
+
return _resolve_body_file_value(full_value_token)
|
|
221
|
+
token_index += 1
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def extract_body_from_command(
|
|
226
|
+
command: str,
|
|
227
|
+
pre_tokenized: tuple[str, list[str]] | None = None,
|
|
228
|
+
) -> str | None:
|
|
229
|
+
"""Return the PR body content for validation, or None if unextractable.
|
|
230
|
+
|
|
231
|
+
Uses iter_significant_tokens to skip values of non-body value-taking flags
|
|
232
|
+
so that --body/--body-file embedded in a quoted --title value never false-matches.
|
|
233
|
+
For space-form body-file flags, scans the raw token list directly because
|
|
234
|
+
iter_significant_tokens consumes the value token (yielding remaining-after-value).
|
|
235
|
+
|
|
236
|
+
If pre_tokenized is provided as (logical_line, raw_tokens), reuses those instead
|
|
237
|
+
of recomputing the logical line and shlex split a second time.
|
|
238
|
+
"""
|
|
239
|
+
if pre_tokenized is not None:
|
|
240
|
+
logical_line, all_raw_tokens = pre_tokenized
|
|
241
|
+
else:
|
|
242
|
+
logical_line = get_logical_first_line(command)
|
|
243
|
+
if not logical_line:
|
|
244
|
+
return None
|
|
245
|
+
try:
|
|
246
|
+
all_raw_tokens = shlex.split(logical_line, posix=False)
|
|
247
|
+
except ValueError:
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
all_significant_tokens = list(
|
|
251
|
+
iter_significant_tokens(command, pre_tokenized=(logical_line, all_raw_tokens))
|
|
252
|
+
)
|
|
253
|
+
except ValueError:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
significant_token_set = {each_token for each_token, _ in all_significant_tokens}
|
|
257
|
+
body_flag_found_in_significant = (
|
|
258
|
+
any(each_token in all_body_flags for each_token in significant_token_set)
|
|
259
|
+
or any(_match_body_flag_equals_prefix(each_token) is not None for each_token in significant_token_set)
|
|
260
|
+
or any(_match_body_file_equals_prefix(each_token) is not None for each_token in significant_token_set)
|
|
261
|
+
or any(each_token in {body_file_flag, body_file_short_flag} for each_token in significant_token_set)
|
|
262
|
+
)
|
|
263
|
+
if not body_flag_found_in_significant:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
result = _scan_raw_tokens_for_body(all_raw_tokens)
|
|
267
|
+
if result is False:
|
|
268
|
+
return None
|
|
269
|
+
return result
|
|
37
270
|
|
|
38
271
|
|
|
39
272
|
def validate_pr_body(body: str) -> list[str]:
|
|
@@ -83,6 +316,9 @@ def main() -> None:
|
|
|
83
316
|
|
|
84
317
|
body = extract_body_from_command(command)
|
|
85
318
|
|
|
319
|
+
if body is None:
|
|
320
|
+
sys.exit(0)
|
|
321
|
+
|
|
86
322
|
if not body:
|
|
87
323
|
sys.exit(0)
|
|
88
324
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
BDD Automate-phase
|
|
3
|
+
BDD Automate-phase gate (production code touch).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Blocks writes to production source files when no matching test exists
|
|
6
|
+
or the matching test has not been modified within the configured
|
|
7
|
+
freshness window. Enforces "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
|
|
7
8
|
"""
|
|
8
9
|
import json
|
|
10
|
+
import re
|
|
9
11
|
import sys
|
|
12
|
+
import time
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
|
|
12
15
|
PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
|
|
@@ -17,12 +20,201 @@ SKIP_PATTERNS = {
|
|
|
17
20
|
SKIP_EXTENSIONS = {'.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.txt'}
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
def _freshness_seconds() -> int:
|
|
24
|
+
return 600
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _bypass_sentinel() -> str:
|
|
28
|
+
return "# pragma: no-tdd-gate"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tests_directory_name() -> str:
|
|
32
|
+
return "tests"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parent_walk_limit() -> int:
|
|
36
|
+
return 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _repo_boundary_sentinels() -> frozenset[str]:
|
|
40
|
+
return frozenset({".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
|
|
44
|
+
return (
|
|
45
|
+
re.compile(r"\bdef\s+test_"),
|
|
46
|
+
re.compile(r"\b(?:it|test|describe)\s*\("),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _directory_skip_components() -> frozenset[str]:
|
|
51
|
+
return frozenset({
|
|
52
|
+
"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_repo_boundary(candidate_directory: Path) -> bool:
|
|
57
|
+
for each_sentinel in _repo_boundary_sentinels():
|
|
58
|
+
if (candidate_directory / each_sentinel).exists():
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_nearest_tests_directory(start_directory: Path) -> Path | None:
|
|
64
|
+
current_directory = start_directory
|
|
65
|
+
for _ in range(_parent_walk_limit()):
|
|
66
|
+
sibling_tests = current_directory / _tests_directory_name()
|
|
67
|
+
if sibling_tests.is_dir():
|
|
68
|
+
return sibling_tests
|
|
69
|
+
if _is_repo_boundary(current_directory):
|
|
70
|
+
return None
|
|
71
|
+
if current_directory.parent == current_directory:
|
|
72
|
+
return None
|
|
73
|
+
current_directory = current_directory.parent
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
78
|
+
directory = production_path.parent
|
|
79
|
+
stem = production_path.stem
|
|
80
|
+
extension = production_path.suffix.lower()
|
|
81
|
+
all_candidates: list[Path] = []
|
|
82
|
+
|
|
83
|
+
if extension == ".py":
|
|
84
|
+
all_candidates.append(directory / f"test_{stem}.py")
|
|
85
|
+
all_candidates.append(directory / f"{stem}_test.py")
|
|
86
|
+
nearest_tests_directory = find_nearest_tests_directory(directory)
|
|
87
|
+
if nearest_tests_directory is not None:
|
|
88
|
+
all_candidates.append(nearest_tests_directory / f"test_{stem}.py")
|
|
89
|
+
return all_candidates
|
|
90
|
+
|
|
91
|
+
if extension in {".tsx", ".ts", ".jsx", ".js"}:
|
|
92
|
+
all_candidates.append(directory / f"{stem}.test{extension}")
|
|
93
|
+
all_candidates.append(directory / f"{stem}.spec{extension}")
|
|
94
|
+
return all_candidates
|
|
95
|
+
|
|
96
|
+
return all_candidates
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _test_file_encoding() -> str:
|
|
100
|
+
return "utf-8"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _safe_mtime(candidate_path: Path) -> float | None:
|
|
104
|
+
try:
|
|
105
|
+
return candidate_path.stat().st_mtime
|
|
106
|
+
except (FileNotFoundError, OSError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_candidate_text(candidate_path: Path) -> str | None:
|
|
111
|
+
try:
|
|
112
|
+
with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
|
|
113
|
+
return each_file.read()
|
|
114
|
+
except (FileNotFoundError, OSError):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _contains_test_evidence(candidate_path: Path) -> bool:
|
|
119
|
+
test_file_content = _read_candidate_text(candidate_path)
|
|
120
|
+
if test_file_content is None:
|
|
121
|
+
return False
|
|
122
|
+
for each_pattern in _test_function_patterns():
|
|
123
|
+
if each_pattern.search(test_file_content):
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def has_fresh_test(
|
|
129
|
+
all_candidates: list[Path],
|
|
130
|
+
freshness_seconds: int,
|
|
131
|
+
) -> bool:
|
|
132
|
+
current_time = time.time()
|
|
133
|
+
for each_candidate in all_candidates:
|
|
134
|
+
candidate_mtime = _safe_mtime(each_candidate)
|
|
135
|
+
if candidate_mtime is None:
|
|
136
|
+
continue
|
|
137
|
+
age_seconds = current_time - candidate_mtime
|
|
138
|
+
if age_seconds > freshness_seconds:
|
|
139
|
+
continue
|
|
140
|
+
if not _contains_test_evidence(each_candidate):
|
|
141
|
+
continue
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
|
|
147
|
+
candidate_lines = "\n".join(f" - {each_path}" for each_path in all_candidates)
|
|
148
|
+
return (
|
|
149
|
+
f"[TDD] Blocking write to production file: {production_path}\n"
|
|
150
|
+
f"No matching test file exists, or it has not been modified within the last "
|
|
151
|
+
f"{_freshness_seconds()} seconds.\n"
|
|
152
|
+
f"Expected one of:\n{candidate_lines}\n"
|
|
153
|
+
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n"
|
|
154
|
+
f"Bypass (discouraged): include the sentinel '{_bypass_sentinel()}' in the file content."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def emit_allow() -> None:
|
|
159
|
+
allow_payload = {
|
|
160
|
+
"hookSpecificOutput": {
|
|
161
|
+
"hookEventName": "PreToolUse",
|
|
162
|
+
"permissionDecision": "allow",
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
print(json.dumps(allow_payload))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def emit_deny(reason: str) -> None:
|
|
169
|
+
deny_payload = {
|
|
170
|
+
"hookSpecificOutput": {
|
|
171
|
+
"hookEventName": "PreToolUse",
|
|
172
|
+
"permissionDecision": "deny",
|
|
173
|
+
"permissionDecisionReason": reason,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
print(json.dumps(deny_payload))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
180
|
+
path_components_lower = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
|
|
181
|
+
directory_components = path_components_lower[:-1]
|
|
182
|
+
skip_directory_components = _directory_skip_components()
|
|
183
|
+
for each_directory_component in directory_components:
|
|
184
|
+
if each_directory_component in skip_directory_components:
|
|
185
|
+
return True
|
|
186
|
+
for each_pattern in SKIP_PATTERNS:
|
|
187
|
+
if each_pattern.endswith("/"):
|
|
188
|
+
if each_pattern in path_with_forward_slashes:
|
|
189
|
+
return True
|
|
190
|
+
continue
|
|
191
|
+
if each_pattern in name_lower:
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_written_content(tool_name: str, tool_input: dict) -> str:
|
|
197
|
+
if tool_name == "Write":
|
|
198
|
+
return tool_input.get("content", "") or ""
|
|
199
|
+
if tool_name == "Edit":
|
|
200
|
+
return tool_input.get("new_string", "") or ""
|
|
201
|
+
if tool_name == "MultiEdit":
|
|
202
|
+
all_edits = tool_input.get("edits", []) or []
|
|
203
|
+
joined_new_strings: list[str] = []
|
|
204
|
+
for each_edit in all_edits:
|
|
205
|
+
if isinstance(each_edit, dict):
|
|
206
|
+
joined_new_strings.append(each_edit.get("new_string", "") or "")
|
|
207
|
+
return "\n".join(joined_new_strings)
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
|
|
20
211
|
def main() -> None:
|
|
21
212
|
try:
|
|
22
213
|
input_data = json.load(sys.stdin)
|
|
23
214
|
except json.JSONDecodeError:
|
|
24
215
|
sys.exit(0)
|
|
25
216
|
|
|
217
|
+
tool_name = input_data.get("tool_name", "")
|
|
26
218
|
tool_input = input_data.get("tool_input", {})
|
|
27
219
|
file_path = tool_input.get("file_path", "")
|
|
28
220
|
|
|
@@ -42,19 +234,22 @@ def main() -> None:
|
|
|
42
234
|
|
|
43
235
|
# Skip test files
|
|
44
236
|
name_lower = path.name.lower()
|
|
45
|
-
path_str = str(path).lower()
|
|
46
|
-
if
|
|
237
|
+
path_str = str(path).lower().replace("\\", "/")
|
|
238
|
+
if _matches_any_skip_pattern(name_lower, path_str):
|
|
47
239
|
sys.exit(0)
|
|
48
240
|
|
|
49
241
|
# Block production code - require confirmation
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
242
|
+
written_content = _extract_written_content(tool_name, tool_input)
|
|
243
|
+
if _bypass_sentinel() in written_content:
|
|
244
|
+
emit_allow()
|
|
245
|
+
sys.exit(0)
|
|
246
|
+
|
|
247
|
+
all_candidates = candidate_test_paths_for(path)
|
|
248
|
+
if has_fresh_test(all_candidates, _freshness_seconds()):
|
|
249
|
+
emit_allow()
|
|
250
|
+
sys.exit(0)
|
|
251
|
+
|
|
252
|
+
emit_deny(build_deny_reason(path, all_candidates))
|
|
58
253
|
sys.exit(0)
|
|
59
254
|
|
|
60
255
|
|