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.
Files changed (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. 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