claude-dev-env 1.50.0 → 1.50.2
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/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_annotations_length.py +167 -0
- package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
- package/hooks/blocking/code_rules_comments.py +337 -0
- package/hooks/blocking/code_rules_constants_config.py +252 -0
- package/hooks/blocking/code_rules_docstrings.py +308 -0
- package/hooks/blocking/code_rules_enforcer.py +98 -5765
- package/hooks/blocking/code_rules_imports_logging.py +276 -0
- package/hooks/blocking/code_rules_magic_values.py +180 -0
- package/hooks/blocking/code_rules_mock_completeness.py +295 -0
- package/hooks/blocking/code_rules_naming_collection.py +264 -0
- package/hooks/blocking/code_rules_optional_params.py +288 -0
- package/hooks/blocking/code_rules_paths_syspath.py +186 -0
- package/hooks/blocking/code_rules_probe_chains.py +305 -0
- package/hooks/blocking/code_rules_probe_detection.py +257 -0
- package/hooks/blocking/code_rules_probe_recording.py +225 -0
- package/hooks/blocking/code_rules_scope_binding.py +151 -0
- package/hooks/blocking/code_rules_shared.py +301 -0
- package/hooks/blocking/code_rules_string_magic.py +207 -0
- package/hooks/blocking/code_rules_test_assertions.py +226 -0
- package/hooks/blocking/code_rules_test_branching_except.py +181 -0
- package/hooks/blocking/code_rules_test_isolation.py +341 -0
- package/hooks/blocking/code_rules_type_escape.py +341 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
- package/hooks/blocking/code_rules_unused_imports.py +256 -0
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/tdd_enforcer.py +31 -0
- package/hooks/blocking/test_code_rules_constants_config.py +26 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
- package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
- package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
- package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
- package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
- package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
- package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
- package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/blocking/test_tdd_enforcer.py +116 -0
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
- package/hooks/blocking/test_md_to_html_blocker.py +0 -810
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Unit tests for pr-description-enforcer gh command parsing."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
13
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
14
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
16
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
18
|
+
|
|
19
|
+
from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
20
|
+
get_logical_first_line,
|
|
21
|
+
iter_significant_tokens,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
parser_spec = importlib.util.spec_from_file_location(
|
|
25
|
+
"pr_description_command_parser",
|
|
26
|
+
_HOOK_DIR / "pr_description_command_parser.py",
|
|
27
|
+
)
|
|
28
|
+
assert parser_spec is not None
|
|
29
|
+
assert parser_spec.loader is not None
|
|
30
|
+
hook_module = importlib.util.module_from_spec(parser_spec)
|
|
31
|
+
parser_spec.loader.exec_module(hook_module)
|
|
32
|
+
extract_body_from_command = hook_module.extract_body_from_command
|
|
33
|
+
|
|
34
|
+
VALID_BODY = (
|
|
35
|
+
"Allow commas in branch names so PRs whose head branch was generated from "
|
|
36
|
+
"a title or external identifier no longer fail validation before any git "
|
|
37
|
+
"operation.\n\n"
|
|
38
|
+
"Fixes #1300.\n\n"
|
|
39
|
+
"## Changes\n\n"
|
|
40
|
+
"- `src/github/operations/branch.ts`: add `,` to the whitelist regex\n"
|
|
41
|
+
"- `test/branch.test.ts`: 3 new cases covering comma-bearing branch names\n\n"
|
|
42
|
+
"## Test plan\n\n"
|
|
43
|
+
"- `bun test test/branch.test.ts`\n"
|
|
44
|
+
"- `bun run typecheck`\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_extract_body_from_body_string() -> None:
|
|
49
|
+
command = 'gh pr create --title "T" --body "Description and some text."'
|
|
50
|
+
assert "Description" in extract_body_from_command(command)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_extract_body_from_body_file_space_form(tmp_path: pathlib.Path) -> None:
|
|
54
|
+
body_file = tmp_path / "body.md"
|
|
55
|
+
body_file.write_text(VALID_BODY)
|
|
56
|
+
command = f'gh pr create --title "T" --body-file {body_file}'
|
|
57
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_extract_body_from_body_file_equals_form(tmp_path: pathlib.Path) -> None:
|
|
61
|
+
body_file = tmp_path / "body.md"
|
|
62
|
+
body_file.write_text(VALID_BODY)
|
|
63
|
+
command = f'gh pr create --title "T" --body-file="{body_file}"'
|
|
64
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_extract_body_from_body_file_equals_form_with_spaces(
|
|
68
|
+
tmp_path: pathlib.Path,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Quoted --body-file=VALUE with spaces in path must be reassembled, not truncated."""
|
|
71
|
+
body_file = tmp_path / "my body with spaces.md"
|
|
72
|
+
body_file.write_text(VALID_BODY)
|
|
73
|
+
command = f'gh pr create --title "T" --body-file="{body_file}"'
|
|
74
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_extract_body_file_missing_path_returns_none() -> None:
|
|
78
|
+
command = 'gh pr create --title "T" --body-file /nonexistent/path.md'
|
|
79
|
+
assert extract_body_from_command(command) is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_extract_body_file_shell_variable_returns_none() -> None:
|
|
83
|
+
"""Shell variables like $bodyPath can't be resolved at hook time -- return None to skip enforcement."""
|
|
84
|
+
command = 'gh pr create --title "T" --body-file $bodyPath'
|
|
85
|
+
assert extract_body_from_command(command) is None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_extract_body_file_no_false_positive_in_title() -> None:
|
|
89
|
+
command = 'gh pr create --title "use --body-file /tmp/x.md" --body "actual body"'
|
|
90
|
+
extracted_body = extract_body_from_command(command)
|
|
91
|
+
assert extracted_body == "actual body"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_no_false_positive_body_in_title_string_value() -> None:
|
|
95
|
+
command = 'gh pr create --title \'use --body "x"\' --body "actual body"'
|
|
96
|
+
assert extract_body_from_command(command) == "actual body"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_extract_body_from_body_equals_double_quote_form() -> None:
|
|
100
|
+
command = 'gh pr create --title "T" --body="Some body text here."'
|
|
101
|
+
assert extract_body_from_command(command) == "Some body text here."
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_extract_body_from_body_equals_single_quote_form() -> None:
|
|
105
|
+
command = "gh pr create --title 'T' --body='Some body text here.'"
|
|
106
|
+
assert extract_body_from_command(command) == "Some body text here."
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_extract_body_equals_shell_var_returns_none() -> None:
|
|
110
|
+
"""Shell variable like --body=$bodyText cannot be resolved at hook time -- the
|
|
111
|
+
extractor must signal this with None (unauditable), not empty string. An
|
|
112
|
+
empty-string return value is reserved for a literal `--body ""` which should
|
|
113
|
+
still be validated and blocked by the substantive-prose check."""
|
|
114
|
+
command = 'gh pr create --title "T" --body=$bodyText'
|
|
115
|
+
assert extract_body_from_command(command) is None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_extract_short_flag_equals_form() -> None:
|
|
119
|
+
command = 'gh pr create --title "T" -b="Some body text here."'
|
|
120
|
+
assert extract_body_from_command(command) == "Some body text here."
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_extract_short_flag_shell_var_returns_none() -> None:
|
|
124
|
+
"""Short-flag shell variable like -b=$var cannot be resolved at hook time --
|
|
125
|
+
the extractor returns None (unauditable). Literal -b="" still returns ""."""
|
|
126
|
+
command = 'gh pr create --title "T" -b=$bodyVar'
|
|
127
|
+
assert extract_body_from_command(command) is None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_extract_body_string_value_skips_body_file_path_token() -> None:
|
|
131
|
+
command = 'gh pr create --body-file --body "actual text"'
|
|
132
|
+
assert extract_body_from_command(command) is None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_get_logical_first_line_does_not_join_bash_command_substitution() -> None:
|
|
136
|
+
command = 'VAR=`cmd`\ngh pr create --body "text"'
|
|
137
|
+
assert get_logical_first_line(command) == "VAR=`cmd`"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_get_logical_first_line_joins_powershell_backtick_continuation() -> None:
|
|
141
|
+
command = 'Some-Command -Param `\n"value"'
|
|
142
|
+
assert get_logical_first_line(command) == 'Some-Command -Param "value"'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_extract_body_from_body_file_short_F_form(tmp_path: pathlib.Path) -> None:
|
|
146
|
+
"""`gh pr create -F PATH` (short form of --body-file) must read the file."""
|
|
147
|
+
body_file = tmp_path / "body.md"
|
|
148
|
+
body_file.write_text(VALID_BODY)
|
|
149
|
+
command = f'gh pr create --title "T" -F {body_file}'
|
|
150
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_extract_body_ignores_body_inside_title_quoted_value() -> None:
|
|
154
|
+
"""Migration to shared iterator: `--title "contains --body here"` must not false-match."""
|
|
155
|
+
command = 'gh pr create --title "contains --body here" --body-file /tmp/real.md'
|
|
156
|
+
extracted_body = extract_body_from_command(command)
|
|
157
|
+
assert extracted_body is None or extracted_body == ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_extract_body_reassembles_split_quoted_equals_value() -> None:
|
|
161
|
+
"""`--body="has multiple spaces inside"` must reassemble across posix=False tokens."""
|
|
162
|
+
command = 'gh pr create --title "T" --body="this body has multiple words"'
|
|
163
|
+
assert extract_body_from_command(command) == "this body has multiple words"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_read_body_file_rejects_relative_path_traversal(tmp_path, monkeypatch) -> None:
|
|
167
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
168
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
169
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
170
|
+
spec = importlib.util.spec_from_file_location(
|
|
171
|
+
"pde", _HOOK_DIR / "pr_description_command_parser.py"
|
|
172
|
+
)
|
|
173
|
+
m = importlib.util.module_from_spec(spec)
|
|
174
|
+
spec.loader.exec_module(m)
|
|
175
|
+
|
|
176
|
+
sentinel_directory = tmp_path / "sentinel"
|
|
177
|
+
sentinel_directory.mkdir()
|
|
178
|
+
working_directory = tmp_path / "workdir"
|
|
179
|
+
working_directory.mkdir()
|
|
180
|
+
sentinel_file = sentinel_directory / "secret.txt"
|
|
181
|
+
sentinel_file.write_text("secret")
|
|
182
|
+
monkeypatch.chdir(working_directory)
|
|
183
|
+
rel_path = os.path.relpath(str(sentinel_file))
|
|
184
|
+
assert ".." in rel_path, "chdir to a sibling of the sentinel must produce a traversal relpath"
|
|
185
|
+
with pytest.raises(m.PathTraversalError):
|
|
186
|
+
m._read_body_file_contents(rel_path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_read_body_file_allows_absolute_path_outside_cwd(tmp_path) -> None:
|
|
190
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
191
|
+
spec = importlib.util.spec_from_file_location(
|
|
192
|
+
"pde2", _HOOK_DIR / "pr_description_command_parser.py"
|
|
193
|
+
)
|
|
194
|
+
m = importlib.util.module_from_spec(spec)
|
|
195
|
+
spec.loader.exec_module(m)
|
|
196
|
+
body_file = tmp_path / "body.md"
|
|
197
|
+
body_file.write_text("hello")
|
|
198
|
+
result = m._read_body_file_contents(str(body_file))
|
|
199
|
+
assert result == "hello"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_reassemble_split_quoted_value_returns_none_for_unclosed_quote() -> None:
|
|
203
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
204
|
+
spec = importlib.util.spec_from_file_location(
|
|
205
|
+
"pde3", _HOOK_DIR / "pr_description_command_parser.py"
|
|
206
|
+
)
|
|
207
|
+
m = importlib.util.module_from_spec(spec)
|
|
208
|
+
spec.loader.exec_module(m)
|
|
209
|
+
result = m._reassemble_split_quoted_value("'unclosed", [])
|
|
210
|
+
assert result is None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_extract_body_returns_none_for_unclosed_quote_value() -> None:
|
|
214
|
+
result = extract_body_from_command("gh pr create --title T --body='unclosed")
|
|
215
|
+
assert result is None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_body_file_stdin_sentinel_returns_none() -> None:
|
|
219
|
+
"""--body-file - (stdin sentinel) must return None so enforcer skips validation."""
|
|
220
|
+
command = 'gh pr create --title "T" --body-file -'
|
|
221
|
+
assert extract_body_from_command(command) is None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_body_file_shell_variable_returns_none() -> None:
|
|
225
|
+
"""--body-file $VAR cannot be audited at hook time -- must return None, not empty string."""
|
|
226
|
+
command = 'gh pr create --title "T" --body-file $BODY_VAR'
|
|
227
|
+
assert extract_body_from_command(command) is None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_body_file_path_traversal_returns_none() -> None:
|
|
231
|
+
"""Path traversal rejection must return None so enforcer does not raise false positive."""
|
|
232
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
233
|
+
spec = importlib.util.spec_from_file_location(
|
|
234
|
+
"pde_t", _HOOK_DIR / "pr_description_command_parser.py"
|
|
235
|
+
)
|
|
236
|
+
m = importlib.util.module_from_spec(spec)
|
|
237
|
+
spec.loader.exec_module(m)
|
|
238
|
+
result = m._resolve_body_file_value("../../../etc/passwd")
|
|
239
|
+
assert result is None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_read_body_file_rejects_absolute_symlink_outside_cwd(tmp_path: pathlib.Path) -> None:
|
|
243
|
+
"""Absolute symlink pointing outside cwd must raise PathTraversalError."""
|
|
244
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
245
|
+
spec = importlib.util.spec_from_file_location(
|
|
246
|
+
"pde_sym", _HOOK_DIR / "pr_description_command_parser.py"
|
|
247
|
+
)
|
|
248
|
+
m = importlib.util.module_from_spec(spec)
|
|
249
|
+
spec.loader.exec_module(m)
|
|
250
|
+
target_file = tmp_path / "secret.txt"
|
|
251
|
+
target_file.write_text("secret content")
|
|
252
|
+
link_path = tmp_path / "evil_link"
|
|
253
|
+
try:
|
|
254
|
+
link_path.symlink_to(target_file)
|
|
255
|
+
except (OSError, NotImplementedError):
|
|
256
|
+
pytest.skip("symlinks not supported on this platform")
|
|
257
|
+
with pytest.raises(m.PathTraversalError):
|
|
258
|
+
m._read_body_file_contents(str(link_path))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_read_body_file_allows_real_absolute_file_inside_cwd(tmp_path: pathlib.Path) -> None:
|
|
262
|
+
"""Real absolute file path that exists must be read successfully."""
|
|
263
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
264
|
+
spec = importlib.util.spec_from_file_location(
|
|
265
|
+
"pde_abs", _HOOK_DIR / "pr_description_command_parser.py"
|
|
266
|
+
)
|
|
267
|
+
m = importlib.util.module_from_spec(spec)
|
|
268
|
+
spec.loader.exec_module(m)
|
|
269
|
+
body_file = tmp_path / "body.md"
|
|
270
|
+
body_file.write_text("hello body")
|
|
271
|
+
result = m._read_body_file_contents(str(body_file))
|
|
272
|
+
assert result == "hello body"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_read_body_file_allows_in_cwd_symlink_pointing_into_cwd(tmp_path: pathlib.Path) -> None:
|
|
276
|
+
"""Symlink inside cwd pointing to another file inside cwd must be readable."""
|
|
277
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
278
|
+
spec = importlib.util.spec_from_file_location(
|
|
279
|
+
"pde_inlink", _HOOK_DIR / "pr_description_command_parser.py"
|
|
280
|
+
)
|
|
281
|
+
m = importlib.util.module_from_spec(spec)
|
|
282
|
+
spec.loader.exec_module(m)
|
|
283
|
+
real_file = tmp_path / "real.md"
|
|
284
|
+
real_file.write_text("real content")
|
|
285
|
+
link_file = tmp_path / "link.md"
|
|
286
|
+
try:
|
|
287
|
+
link_file.symlink_to(real_file)
|
|
288
|
+
except (OSError, NotImplementedError):
|
|
289
|
+
pytest.skip("symlinks not supported on this platform")
|
|
290
|
+
with patch("pathlib.Path.cwd", return_value=tmp_path):
|
|
291
|
+
result = m._read_body_file_contents(str(link_file))
|
|
292
|
+
assert result == "real content"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_iter_significant_tokens_unclosed_quote_raises_value_error() -> None:
|
|
296
|
+
"""Unclosed quoted value in a value-taking flag raises ValueError so callers block conservatively.
|
|
297
|
+
|
|
298
|
+
For equals-form: --title="unclosed raises ValueError (unclosed quote not in remaining tokens).
|
|
299
|
+
For space-form: shlex.split itself raises ValueError before iter_significant_tokens is entered.
|
|
300
|
+
Both paths result in ValueError propagating to callers.
|
|
301
|
+
"""
|
|
302
|
+
with pytest.raises(ValueError):
|
|
303
|
+
list(iter_significant_tokens('gh pr create --title="unclosed --body real_body'))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_scan_raw_tokens_does_not_false_match_body_in_title_value(tmp_path: pathlib.Path) -> None:
|
|
307
|
+
"""--title 'using --body-file is required' must not match --body-file inside the title value."""
|
|
308
|
+
body_file = tmp_path / "real_body.md"
|
|
309
|
+
body_file.write_text(VALID_BODY)
|
|
310
|
+
command = f'gh pr create --title "using --body-file is required" --body-file {body_file}'
|
|
311
|
+
result = extract_body_from_command(command)
|
|
312
|
+
assert result == VALID_BODY
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_scan_raw_tokens_for_body_docstring_reflects_none_for_shell_vars() -> None:
|
|
316
|
+
"""`_resolve_body_string_value` now returns `None` for unresolvable
|
|
317
|
+
shell-variable bodies. `_scan_raw_tokens_for_body`'s docstring must
|
|
318
|
+
reflect that contract so future maintainers do not treat `""` as the
|
|
319
|
+
shell-var sentinel; literal-empty bodies still flow into validation."""
|
|
320
|
+
source_text = inspect.getsource(hook_module._scan_raw_tokens_for_body)
|
|
321
|
+
assert "None" in source_text, (
|
|
322
|
+
f"docstring must mention None for shell-var case; got: {source_text!r}"
|
|
323
|
+
)
|
|
324
|
+
assert "shell var" in source_text.lower() or "shell-var" in source_text.lower(), (
|
|
325
|
+
f"docstring must reference shell variables; got: {source_text!r}"
|
|
326
|
+
)
|
|
327
|
+
assert "may be empty for shell vars/sentinels" not in source_text, (
|
|
328
|
+
'docstring must not claim `""` represents shell-var bodies; that case now returns None. '
|
|
329
|
+
f"Source still contains the stale phrase: {source_text!r}"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_stdlib_imports_form_one_isort_sorted_block() -> None:
|
|
334
|
+
"""Ruff's `I` (isort) rule treats a blank line as a section break, so
|
|
335
|
+
`import shlex` sitting alone after a blank line would fail I001. Pin
|
|
336
|
+
that the stdlib imports at the head of `pr_description_command_parser.py`
|
|
337
|
+
sit in a single sorted block with no internal blank lines."""
|
|
338
|
+
enforcer_source = inspect.getsource(hook_module)
|
|
339
|
+
enforcer_lines = enforcer_source.splitlines()
|
|
340
|
+
leading_stdlib_lines: list[str] = []
|
|
341
|
+
for each_line in enforcer_lines:
|
|
342
|
+
if each_line.startswith("import ") or each_line.startswith("from "):
|
|
343
|
+
leading_stdlib_lines.append(each_line)
|
|
344
|
+
continue
|
|
345
|
+
if each_line.strip() == "":
|
|
346
|
+
if leading_stdlib_lines and leading_stdlib_lines[-1].startswith("from "):
|
|
347
|
+
break
|
|
348
|
+
if leading_stdlib_lines:
|
|
349
|
+
break
|
|
350
|
+
continue
|
|
351
|
+
if not each_line.startswith("import ") and not each_line.startswith("from ") and each_line.strip() != "":
|
|
352
|
+
if leading_stdlib_lines:
|
|
353
|
+
break
|
|
354
|
+
continue
|
|
355
|
+
stdlib_import_names: list[str] = []
|
|
356
|
+
for each_import_line in leading_stdlib_lines:
|
|
357
|
+
if each_import_line.startswith("import "):
|
|
358
|
+
stdlib_import_names.append(each_import_line.split()[1])
|
|
359
|
+
assert "shlex" in stdlib_import_names, (
|
|
360
|
+
"`shlex` must appear in the leading stdlib import block; got: "
|
|
361
|
+
f"{stdlib_import_names!r}"
|
|
362
|
+
)
|
|
363
|
+
assert stdlib_import_names == sorted(stdlib_import_names), (
|
|
364
|
+
"Leading stdlib `import X` statements must be isort-sorted; got: "
|
|
365
|
+
f"{stdlib_import_names!r}"
|
|
366
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Unit tests for pr-description-enforcer PR-number and body-flag detection."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
9
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
10
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
12
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
14
|
+
|
|
15
|
+
pr_number_spec = importlib.util.spec_from_file_location(
|
|
16
|
+
"pr_description_pr_number",
|
|
17
|
+
_HOOK_DIR / "pr_description_pr_number.py",
|
|
18
|
+
)
|
|
19
|
+
assert pr_number_spec is not None
|
|
20
|
+
assert pr_number_spec.loader is not None
|
|
21
|
+
hook_module = importlib.util.module_from_spec(pr_number_spec)
|
|
22
|
+
pr_number_spec.loader.exec_module(hook_module)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_extract_pr_number_from_gh_pr_edit() -> None:
|
|
26
|
+
command = 'gh pr edit 467 --body "some body text here"'
|
|
27
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_extract_pr_number_from_gh_pr_comment() -> None:
|
|
31
|
+
command = 'gh pr comment 467 --body "some comment body"'
|
|
32
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_extract_pr_number_from_gh_pr_create_returns_none() -> None:
|
|
36
|
+
command = 'gh pr create --repo jl-cmd/claude-code-config --body "some body"'
|
|
37
|
+
assert hook_module._extract_pr_number_from_command(command) is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_extract_pr_number_from_malformed_command_returns_none() -> None:
|
|
41
|
+
command = 'gh pr edit --body "body without positional"'
|
|
42
|
+
assert hook_module._extract_pr_number_from_command(command) is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_extract_pr_number_does_not_pick_up_number_in_title() -> None:
|
|
46
|
+
command = 'gh pr edit 467 --title "PR 999 was bad" --body "some body"'
|
|
47
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_command_carries_body_flag_detects_body_file() -> None:
|
|
51
|
+
"""`--body-file` detection must continue to work after the redundant
|
|
52
|
+
explicit check is removed. The shorter `--body` substring still catches
|
|
53
|
+
`--body-file` because `--body` is a prefix of `--body-file`."""
|
|
54
|
+
assert hook_module._command_carries_body_flag("gh pr create --body-file body.md")
|
|
55
|
+
assert hook_module._command_carries_body_flag("gh pr create --body-file=body.md")
|
|
56
|
+
assert hook_module._command_carries_body_flag("gh pr edit 1 -F body.md")
|
|
57
|
+
assert hook_module._command_carries_body_flag("gh pr edit 1 -F=body.md")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_command_carries_body_flag_does_not_double_check_body_file() -> None:
|
|
61
|
+
"""Pin that the function does NOT execute a redundant `--body-file in command`
|
|
62
|
+
check. `--body` is a substring of `--body-file`, so the longer form is
|
|
63
|
+
matched implicitly by the shorter check. Pin the source so the dead branch
|
|
64
|
+
cannot drift back."""
|
|
65
|
+
source_text = inspect.getsource(hook_module._command_carries_body_flag)
|
|
66
|
+
assert source_text.count('"--body-file"') == 0, (
|
|
67
|
+
f"`--body-file` substring check is redundant with `--body`; remove it. Source:\n{source_text}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_resolve_positional_pr_number_accepts_bare_integer() -> None:
|
|
72
|
+
assert hook_module._resolve_positional_pr_number("467") == 467
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_resolve_positional_pr_number_accepts_pr_url() -> None:
|
|
76
|
+
assert hook_module._resolve_positional_pr_number("https://github.com/o/r/pull/467") == 467
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_resolve_positional_pr_number_rejects_non_pr_url() -> None:
|
|
80
|
+
assert hook_module._resolve_positional_pr_number("https://github.com/o/r/issues/467") is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_resolve_positional_pr_number_rejects_shell_variable() -> None:
|
|
84
|
+
assert hook_module._resolve_positional_pr_number("$PR_NUMBER") is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_extract_pr_number_skips_repo_value_flag() -> None:
|
|
88
|
+
"""gh pr edit --repo owner/r 467 --body "x" must return 467 -- the --repo value must be skipped."""
|
|
89
|
+
command = 'gh pr edit --repo owner/r 467 --body "x"'
|
|
90
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_extract_pr_number_from_pr_url_positional() -> None:
|
|
94
|
+
"""gh pr edit https://github.com/o/r/pull/467 --body "x" must return 467 -- URL form is valid."""
|
|
95
|
+
command = 'gh pr edit https://github.com/o/r/pull/467 --body "x"'
|
|
96
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_extract_pr_number_from_pr_url_after_repo_flag() -> None:
|
|
100
|
+
"""Combined: --repo flag plus URL positional must still resolve to the URL's PR number."""
|
|
101
|
+
command = 'gh pr edit --repo owner/r https://github.com/o/r/pull/999 --body "x"'
|
|
102
|
+
assert hook_module._extract_pr_number_from_command(command) == 999
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_extract_pr_number_skips_repo_equals_form() -> None:
|
|
106
|
+
"""gh pr edit --repo=owner/r 467 --body "x" must return 467 -- the equals-form must also be handled."""
|
|
107
|
+
command = 'gh pr edit --repo=owner/r 467 --body "x"'
|
|
108
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_extract_pr_number_from_pr_url_with_trailing_query_string() -> None:
|
|
112
|
+
"""A PR URL with a `?diff=split` or other trailing query/fragment must still resolve.
|
|
113
|
+
The trailing group `(?:[/?#].*)?` in the URL regex is what makes this work."""
|
|
114
|
+
command = 'gh pr edit https://github.com/o/r/pull/467?diff=split --body "x"'
|
|
115
|
+
assert hook_module._extract_pr_number_from_command(command) == 467
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_extract_pr_number_skips_body_long_flag_value() -> None:
|
|
119
|
+
"""gh pr edit --body "Fixes #999" 472 must return 472 -- the --body value must not
|
|
120
|
+
be treated as a positional argument. Without skipping body-flag values, the body
|
|
121
|
+
text would be parsed as the positional slot and PR-number extraction would fail."""
|
|
122
|
+
command = 'gh pr edit --body "Fixes #999" 472'
|
|
123
|
+
assert hook_module._extract_pr_number_from_command(command) == 472
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_extract_pr_number_skips_body_short_flag_value() -> None:
|
|
127
|
+
"""gh pr edit -b 'Fixes #999' 472 must return 472 -- short -b alias must also skip its value."""
|
|
128
|
+
command = 'gh pr edit -b "Fixes #999" 472'
|
|
129
|
+
assert hook_module._extract_pr_number_from_command(command) == 472
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_extract_pr_number_skips_body_file_long_flag_value() -> None:
|
|
133
|
+
"""gh pr edit --body-file body.md 472 must return 472 -- --body-file value must skip."""
|
|
134
|
+
command = "gh pr edit --body-file body.md 472"
|
|
135
|
+
assert hook_module._extract_pr_number_from_command(command) == 472
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_extract_pr_number_skips_body_file_short_flag_value() -> None:
|
|
139
|
+
"""gh pr edit -F body.md 472 must return 472 -- -F short alias must also skip its value."""
|
|
140
|
+
command = "gh pr edit -F body.md 472"
|
|
141
|
+
assert hook_module._extract_pr_number_from_command(command) == 472
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_extract_pr_number_skips_body_equals_form() -> None:
|
|
145
|
+
"""gh pr edit --body="Fixes #999" 472 must return 472 -- equals-form has the value
|
|
146
|
+
attached to the same token, so only the flag token itself should be skipped."""
|
|
147
|
+
command = 'gh pr edit --body="Fixes #999" 472'
|
|
148
|
+
assert hook_module._extract_pr_number_from_command(command) == 472
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_command_carries_body_flag_short_b_equals_form() -> None:
|
|
152
|
+
"""`-b=value` short form must be detected by the pre-filter; previous version only
|
|
153
|
+
checked the space-separated `-b ` substring and silently bypassed the equals form."""
|
|
154
|
+
assert hook_module._command_carries_body_flag('gh pr edit 123 -b="x"') is True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_command_carries_body_flag_short_F_equals_form() -> None:
|
|
158
|
+
"""`-F=path` short form must be detected by the pre-filter."""
|
|
159
|
+
assert hook_module._command_carries_body_flag("gh pr edit 123 -F=body.md") is True
|