claude-dev-env 1.49.1 → 1.50.1

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 (34) hide show
  1. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +17 -3
  2. package/audit-rubrics/prompts/category-a-api-contracts.md +17 -2
  3. package/docs/CODE_RULES.md +6 -1
  4. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  5. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  6. package/hooks/blocking/code_rules_enforcer.py +386 -32
  7. package/hooks/blocking/conftest.py +30 -0
  8. package/hooks/blocking/md_to_html_blocker.py +2 -2
  9. package/hooks/blocking/pr_description_body_audit.py +148 -0
  10. package/hooks/blocking/pr_description_command_parser.py +233 -0
  11. package/hooks/blocking/pr_description_enforcer.py +36 -825
  12. package/hooks/blocking/pr_description_pr_number.py +153 -0
  13. package/hooks/blocking/pr_description_readability.py +366 -0
  14. package/hooks/blocking/test_code_rules_enforcer.py +65 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
  16. package/hooks/blocking/test_code_rules_enforcer_function_length.py +136 -5
  17. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
  18. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
  19. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  20. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  21. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  22. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  23. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  24. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  25. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  26. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  27. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  28. package/hooks/hooks_constants/blocking_check_limits.py +2 -0
  29. package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
  30. package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
  31. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  32. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
  33. package/package.json +1 -1
  34. package/hooks/blocking/test_md_to_html_blocker.py +0 -772
@@ -0,0 +1,336 @@
1
+ """Tests for md_to_html_blocker path resolution, denial payload, and config.
2
+
3
+ Covers home-relative and tilde path canonicalization, the OS temp-directory
4
+ exemption, cwd-relative resolution against repo and plugin roots, the structure
5
+ of the denial payload (system message, additional context, redirect reason),
6
+ and the module-introspection contracts that pin centralized constants.
7
+ """
8
+
9
+ import importlib
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+
15
+ _BLOCKING_DIRECTORY = os.path.dirname(__file__)
16
+
17
+ if _BLOCKING_DIRECTORY not in sys.path:
18
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
19
+
20
+ from _md_to_html_blocker_test_support import ( # noqa: E402
21
+ HOOK_SCRIPT_PATH,
22
+ _get_sandbox_parent_directory,
23
+ _run_hook,
24
+ )
25
+
26
+ _ALL_HOME_ENVIRONMENT_VARIABLE_NAMES = ("USERPROFILE", "HOME")
27
+ _ALL_TEMP_ENVIRONMENT_VARIABLE_NAMES = ("TMPDIR", "TEMP", "TMP")
28
+
29
+
30
+ def _redirect_home_to(monkeypatch, fake_home_directory):
31
+ """Point every home-directory env read at *fake_home_directory*.
32
+
33
+ The hook runs as a subprocess that inherits os.environ, so setting the
34
+ home env vars here propagates to the hook process. Windows ntpath reads
35
+ USERPROFILE while POSIX reads HOME, so both are set to keep the test's
36
+ expected path and the hook's resolution aligned on either platform.
37
+
38
+ Args:
39
+ monkeypatch: The pytest monkeypatch fixture used to set env vars.
40
+ fake_home_directory: An existing sandbox directory to treat as home.
41
+
42
+ Returns:
43
+ The canonical (realpath) form of *fake_home_directory*, matching the
44
+ canonicalization the exemption resolver applies before comparison.
45
+ """
46
+ for each_home_variable_name in _ALL_HOME_ENVIRONMENT_VARIABLE_NAMES:
47
+ monkeypatch.setenv(each_home_variable_name, fake_home_directory)
48
+ return os.path.realpath(fake_home_directory)
49
+
50
+
51
+ def _redirect_temp_to(monkeypatch, fake_temp_directory):
52
+ """Point every temp-directory env read at *fake_temp_directory*.
53
+
54
+ tempfile.gettempdir() consults TMPDIR, then TEMP, then TMP, so all three
55
+ are set. The hook subprocess is a fresh process, so its
56
+ tempfile.gettempdir() reads these env vars rather than a cached value.
57
+
58
+ Args:
59
+ monkeypatch: The pytest monkeypatch fixture used to set env vars.
60
+ fake_temp_directory: An existing sandbox directory to treat as temp.
61
+
62
+ Returns:
63
+ The canonical (realpath) form of *fake_temp_directory*, matching the
64
+ canonicalization the exemption resolver applies before comparison.
65
+ """
66
+ for each_temp_variable_name in _ALL_TEMP_ENVIRONMENT_VARIABLE_NAMES:
67
+ monkeypatch.setenv(each_temp_variable_name, fake_temp_directory)
68
+ return os.path.realpath(fake_temp_directory)
69
+
70
+
71
+ def _isolate_home_away_from_temp(monkeypatch, base_directory):
72
+ """Redirect home and temp at disjoint subdirectories under *base_directory*.
73
+
74
+ Pointing home and temp at separate trees keeps a home-relative test path
75
+ from also matching the temp-directory exemption, which the resolver checks
76
+ after the home exemption. Both env groups are set so the hook subprocess
77
+ and this test process resolve the same fake home and temp.
78
+
79
+ Args:
80
+ monkeypatch: The pytest monkeypatch fixture used to set env vars.
81
+ base_directory: An existing sandbox directory whose `home` and `temp`
82
+ subdirectories become the fake home and fake temp.
83
+
84
+ Returns:
85
+ The canonical (realpath) form of the fake home directory.
86
+ """
87
+ fake_home_directory = os.path.join(base_directory, "home")
88
+ fake_temp_directory = os.path.join(base_directory, "temp")
89
+ os.makedirs(fake_home_directory, exist_ok=True)
90
+ os.makedirs(fake_temp_directory, exist_ok=True)
91
+ _redirect_temp_to(monkeypatch, fake_temp_directory)
92
+ return _redirect_home_to(monkeypatch, fake_home_directory)
93
+
94
+
95
+ def test_block_messages_mention_claude_dev_env_source_exemptions():
96
+ """Block messages must surface the `packages/claude-dev-env/<dir>/` anchored
97
+ exemption so contributors aren't misled when a `.md` write is denied
98
+ elsewhere. Ensures docs/, rules/, and system-prompts/ source files
99
+ render as writable in the user-facing message."""
100
+ hook_dir = os.path.dirname(HOOK_SCRIPT_PATH)
101
+ if hook_dir not in sys.path:
102
+ sys.path.insert(0, hook_dir)
103
+ blocker_module = importlib.import_module("md_to_html_blocker")
104
+ importlib.reload(blocker_module)
105
+
106
+ context_message = blocker_module._block_context()
107
+ system_message = blocker_module._block_system_message()
108
+ combined_messages = context_message + " " + system_message
109
+ assert "claude-dev-env" in combined_messages, (
110
+ "Block messages must mention claude-dev-env source-directory exemption; "
111
+ f"got context={context_message!r} system={system_message!r}"
112
+ )
113
+
114
+
115
+ def test_module_imports_path_segments_from_hooks_constants():
116
+ """The blocker pulls the two leading path segments (`packages` and
117
+ `claude-dev-env`) through the centralised hooks_constants module rather
118
+ than inlining them as raw string literals."""
119
+ hook_dir = os.path.dirname(HOOK_SCRIPT_PATH)
120
+ if hook_dir not in sys.path:
121
+ sys.path.insert(0, hook_dir)
122
+ blocker_module = importlib.import_module("md_to_html_blocker")
123
+ importlib.reload(blocker_module)
124
+ assert blocker_module.PACKAGES_TOP_LEVEL_SEGMENT == "packages"
125
+ assert blocker_module.CLAUDE_DEV_ENV_REPO_NAME_SEGMENT == "claude-dev-env"
126
+
127
+
128
+ def test_module_imports_top_directories_from_hooks_constants():
129
+ """The exempt-top-directories set must live in `hooks_constants/` rather
130
+ than as a file-global single-use constant in the blocker module. The
131
+ blocker imports the centralized constant; a regression that reintroduces
132
+ a local module-scope copy would fail this assertion."""
133
+ hook_dir = os.path.dirname(HOOK_SCRIPT_PATH)
134
+ if hook_dir not in sys.path:
135
+ sys.path.insert(0, hook_dir)
136
+ blocker_module = importlib.import_module("md_to_html_blocker")
137
+ importlib.reload(blocker_module)
138
+ assert hasattr(blocker_module, "ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES"), (
139
+ "Blocker module must import ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES from "
140
+ "hooks_constants/ (file-global single-use rule)."
141
+ )
142
+ assert not hasattr(blocker_module, "_claude_code_source_top_directories"), (
143
+ "Local _claude_code_source_top_directories must not be re-introduced; "
144
+ "use the imported constant from hooks_constants/ instead."
145
+ )
146
+
147
+
148
+ def test_blocks_relative_readme_when_cwd_is_not_repo_root():
149
+ sandbox_parent = _get_sandbox_parent_directory()
150
+ non_repo_cwd = os.path.join(sandbox_parent, "not-a-repo")
151
+ os.makedirs(non_repo_cwd, exist_ok=True)
152
+ payload = json.dumps(
153
+ {
154
+ "tool_name": "Write",
155
+ "tool_input": {"file_path": "README.md", "content": "# README"},
156
+ }
157
+ )
158
+ result = subprocess.run(
159
+ [sys.executable, HOOK_SCRIPT_PATH],
160
+ input=payload,
161
+ capture_output=True,
162
+ text=True,
163
+ check=False,
164
+ cwd=non_repo_cwd,
165
+ )
166
+ assert result.returncode == 0
167
+ output = json.loads(result.stdout)
168
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
169
+
170
+
171
+ def test_denial_has_system_message():
172
+ result = _run_hook(
173
+ "Write",
174
+ {"file_path": "docs/guide.md", "content": "# Hello"},
175
+ )
176
+ assert result.returncode == 0
177
+ output = json.loads(result.stdout)
178
+ assert output["suppressOutput"] is True
179
+ assert isinstance(output["systemMessage"], str)
180
+ assert len(output["systemMessage"]) > 0
181
+
182
+
183
+ def test_denial_has_additional_context():
184
+ result = _run_hook(
185
+ "Write",
186
+ {"file_path": "docs/guide.md", "content": "# Hello"},
187
+ )
188
+ assert result.returncode == 0
189
+ output = json.loads(result.stdout)
190
+ ctx = output["hookSpecificOutput"].get("additionalContext", "")
191
+ assert "HTML" in ctx
192
+ assert "thariqs.github.io" in output["hookSpecificOutput"]["permissionDecisionReason"]
193
+
194
+
195
+ def test_denial_reason_mentions_html_redirect():
196
+ result = _run_hook(
197
+ "Write",
198
+ {"file_path": "docs/guide.md", "content": "# Hello"},
199
+ )
200
+ assert result.returncode == 0
201
+ output = json.loads(result.stdout)
202
+ reason = output["hookSpecificOutput"]["permissionDecisionReason"]
203
+ assert ".html" in reason.lower()
204
+
205
+
206
+ def test_passes_home_session_log_directory(monkeypatch, tmp_path):
207
+ home_directory = _isolate_home_away_from_temp(monkeypatch, str(tmp_path))
208
+ session_log_path = os.path.join(home_directory, "SessionLog", "decisions", "note.md")
209
+ result = _run_hook(
210
+ "Write",
211
+ {"file_path": session_log_path, "content": "# Note"},
212
+ )
213
+ assert result.returncode == 0
214
+ assert result.stdout == ""
215
+
216
+
217
+ def test_passes_home_claude_plans_directory(monkeypatch, tmp_path):
218
+ home_directory = _isolate_home_away_from_temp(monkeypatch, str(tmp_path))
219
+ plans_path = os.path.join(home_directory, ".claude", "plans", "plan.md")
220
+ result = _run_hook(
221
+ "Write",
222
+ {"file_path": plans_path, "content": "# Plan"},
223
+ )
224
+ assert result.returncode == 0
225
+ assert result.stdout == ""
226
+
227
+
228
+ def test_blocks_home_directory_other_md_file(monkeypatch, tmp_path):
229
+ home_directory = _isolate_home_away_from_temp(monkeypatch, str(tmp_path))
230
+ other_path = os.path.join(home_directory, "docs", "guide.md")
231
+ result = _run_hook(
232
+ "Write",
233
+ {"file_path": other_path, "content": "# Guide"},
234
+ )
235
+ assert result.returncode == 0
236
+ output = json.loads(result.stdout)
237
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
238
+
239
+
240
+ def test_passes_tilde_session_log_path():
241
+ result = _run_hook(
242
+ "Write",
243
+ {"file_path": "~/SessionLog/decisions/note.md", "content": "# Note"},
244
+ )
245
+ assert result.returncode == 0
246
+ assert result.stdout == ""
247
+
248
+
249
+ def test_passes_tilde_claude_plans_path():
250
+ result = _run_hook(
251
+ "Write",
252
+ {"file_path": "~/.claude/plans/plan.md", "content": "# Plan"},
253
+ )
254
+ assert result.returncode == 0
255
+ assert result.stdout == ""
256
+
257
+
258
+ def test_blocks_tilde_other_home_md_file():
259
+ result = _run_hook(
260
+ "Write",
261
+ {"file_path": "~/docs/guide.md", "content": "# Guide"},
262
+ )
263
+ assert result.returncode == 0
264
+ output = json.loads(result.stdout)
265
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
266
+
267
+
268
+ def test_passes_system_temp_directory(monkeypatch, tmp_path):
269
+ temp_directory = _redirect_temp_to(monkeypatch, str(tmp_path))
270
+ temp_md_path = os.path.join(temp_directory, "bugteam-scratch", "pr-body.md")
271
+ result = _run_hook(
272
+ "Write",
273
+ {"file_path": temp_md_path, "content": "# Scratch"},
274
+ )
275
+ assert result.returncode == 0
276
+ assert result.stdout == ""
277
+
278
+
279
+ def test_passes_relative_path_from_home_cwd(monkeypatch, tmp_path):
280
+ home_directory = _isolate_home_away_from_temp(monkeypatch, str(tmp_path))
281
+ payload = json.dumps(
282
+ {
283
+ "tool_name": "Write",
284
+ "tool_input": {
285
+ "file_path": "SessionLog/decisions/note.md",
286
+ "content": "# Note",
287
+ },
288
+ }
289
+ )
290
+ result = subprocess.run(
291
+ [sys.executable, HOOK_SCRIPT_PATH],
292
+ input=payload,
293
+ capture_output=True,
294
+ text=True,
295
+ check=False,
296
+ cwd=home_directory,
297
+ )
298
+ assert result.returncode == 0
299
+ assert result.stdout == ""
300
+
301
+
302
+ def test_passes_canonicalized_home_path(monkeypatch, tmp_path):
303
+ canonical_home = _isolate_home_away_from_temp(monkeypatch, str(tmp_path))
304
+ canonical_path = os.path.join(canonical_home, "SessionLog", "canonical-note.md")
305
+ result = _run_hook(
306
+ "Write",
307
+ {"file_path": canonical_path, "content": "# Canonical"},
308
+ )
309
+ assert result.returncode == 0
310
+ assert result.stdout == ""
311
+
312
+
313
+ def test_passes_relative_path_under_cwd_plugin_root_marker(tmp_path):
314
+ plugin_root = tmp_path / "plugin-cwd-repo"
315
+ (plugin_root / ".claude-plugin").mkdir(parents=True)
316
+ (plugin_root / "subdir").mkdir(parents=True)
317
+
318
+ payload = json.dumps(
319
+ {
320
+ "tool_name": "Write",
321
+ "tool_input": {
322
+ "file_path": "subdir/design.md",
323
+ "content": "# Design",
324
+ },
325
+ }
326
+ )
327
+ result = subprocess.run(
328
+ [sys.executable, HOOK_SCRIPT_PATH],
329
+ input=payload,
330
+ capture_output=True,
331
+ text=True,
332
+ check=False,
333
+ cwd=str(plugin_root),
334
+ )
335
+ assert result.returncode == 0
336
+ assert result.stdout == ""