claude-dev-env 1.83.0 → 1.85.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.
Files changed (186) hide show
  1. package/_shared/pr-loop/audit-contract.md +24 -12
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
  6. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  7. package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
  8. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  9. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  13. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
  14. package/agents/clean-coder.md +3 -0
  15. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  16. package/bin/install.mjs +16 -8
  17. package/bin/install.test.mjs +17 -6
  18. package/commands/CLAUDE.md +0 -1
  19. package/docs/CODE_RULES.md +1 -1
  20. package/hooks/blocking/CLAUDE.md +0 -4
  21. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  22. package/hooks/blocking/code_rules_constants_config.py +5 -0
  23. package/hooks/blocking/code_rules_docstrings.py +133 -19
  24. package/hooks/blocking/code_rules_enforcer.py +4 -0
  25. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  26. package/hooks/blocking/code_rules_magic_values.py +5 -0
  27. package/hooks/blocking/code_rules_naming_collection.py +15 -8
  28. package/hooks/blocking/code_rules_paired_test.py +3 -2
  29. package/hooks/blocking/code_rules_shared.py +34 -0
  30. package/hooks/blocking/code_rules_string_magic.py +1 -1
  31. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  33. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  36. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  37. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  38. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  39. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  40. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  41. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  42. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  43. package/hooks/hooks_constants/CLAUDE.md +0 -3
  44. package/hooks/hooks_constants/blocking_check_limits.py +4 -0
  45. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  46. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  47. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  48. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  49. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  50. package/hooks/workflow/CLAUDE.md +2 -8
  51. package/package.json +1 -1
  52. package/rules/CLAUDE.md +1 -1
  53. package/rules/docstring-prose-matches-implementation.md +1 -1
  54. package/rules/env-var-table-code-drift.md +1 -1
  55. package/rules/package-inventory-stale-entry.md +1 -1
  56. package/rules/paired-test-coverage.md +1 -1
  57. package/rules/plain-illustrative-docstrings.md +35 -1
  58. package/skills/CLAUDE.md +6 -1
  59. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  60. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  61. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  62. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  63. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  64. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  65. package/skills/autoconverge/SKILL.md +82 -58
  66. package/skills/autoconverge/reference/closing-report.md +6 -6
  67. package/skills/autoconverge/reference/convergence.md +17 -15
  68. package/skills/autoconverge/reference/gotchas.md +6 -3
  69. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  70. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  71. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  72. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  73. package/skills/autoconverge/workflow/converge.mjs +71 -74
  74. package/skills/autoconverge/workflow/render_report.py +7 -11
  75. package/skills/bugteam/CLAUDE.md +1 -1
  76. package/skills/bugteam/PROMPTS.md +7 -6
  77. package/skills/bugteam/SKILL.md +25 -72
  78. package/skills/bugteam/reference/CLAUDE.md +1 -3
  79. package/skills/bugteam/reference/README.md +1 -1
  80. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  81. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  82. package/skills/bugteam/reference/team-setup.md +8 -7
  83. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  84. package/skills/bugteam/scripts/README.md +0 -4
  85. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  86. package/skills/code/SKILL.md +2 -0
  87. package/skills/copilot-review/CLAUDE.md +1 -1
  88. package/skills/copilot-review/SKILL.md +25 -23
  89. package/skills/findbugs/CLAUDE.md +2 -2
  90. package/skills/findbugs/SKILL.md +22 -83
  91. package/skills/fixbugs/SKILL.md +2 -4
  92. package/skills/log-audit/CLAUDE.md +20 -0
  93. package/skills/log-audit/SKILL.md +68 -0
  94. package/skills/log-audit/reference/CLAUDE.md +9 -0
  95. package/skills/log-audit/reference/charter.md +52 -0
  96. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  97. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  98. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  99. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  100. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  101. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  102. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  103. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  104. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  105. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  106. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  107. package/skills/monitor-open-prs/SKILL.md +2 -2
  108. package/skills/post-audit-findings/SKILL.md +84 -0
  109. package/skills/pr-converge/CLAUDE.md +2 -0
  110. package/skills/pr-converge/SKILL.md +99 -60
  111. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  112. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  113. package/skills/pr-converge/reference/examples.md +5 -5
  114. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  115. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  116. package/skills/pr-converge/reference/per-tick.md +24 -45
  117. package/skills/pr-converge/reference/state-schema.md +27 -0
  118. package/skills/pr-converge/scripts/README.md +3 -5
  119. package/skills/pr-fix-protocol/SKILL.md +70 -0
  120. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  121. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  122. package/skills/pr-scope-resolve/SKILL.md +48 -0
  123. package/skills/qbug/CLAUDE.md +4 -4
  124. package/skills/qbug/SKILL.md +46 -144
  125. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  126. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  127. package/skills/reviewer-gates/SKILL.md +96 -0
  128. package/skills/session-log/CLAUDE.md +7 -7
  129. package/skills/session-log/SKILL.md +27 -44
  130. package/skills/test_markdown_link_integrity.py +103 -0
  131. package/commands/doc-gist.md +0 -16
  132. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  133. package/hooks/blocking/conftest.py +0 -30
  134. package/hooks/blocking/md_path_exemptions.py +0 -224
  135. package/hooks/blocking/md_to_html_blocker.py +0 -155
  136. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  137. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  138. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  139. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  140. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  141. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  142. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  143. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  144. package/hooks/workflow/md_to_html_companion.py +0 -358
  145. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  146. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  147. package/skills/bugteam/reference/audit-contract.md +0 -163
  148. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  149. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  150. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  151. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  152. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  153. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  154. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  155. package/skills/doc-gist/CLAUDE.md +0 -25
  156. package/skills/doc-gist/SKILL.md +0 -97
  157. package/skills/doc-gist/references/CLAUDE.md +0 -9
  158. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  159. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  160. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  161. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  162. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  163. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  164. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  165. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  166. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  167. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  168. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  169. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  170. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  171. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  172. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  173. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  174. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  175. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  176. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  177. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  178. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  179. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  180. package/skills/doc-gist/references/examples/README.md +0 -5
  181. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  182. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  183. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  184. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  185. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  186. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -1,613 +0,0 @@
1
- """Tests for md_to_html_companion hook.
2
-
3
- This test suite validates that the md-to-html companion hook correctly
4
- generates HTML from markdown input, handles edge cases, and produces
5
- valid HTML output.
6
-
7
- Sandbox parent is created lazily by a session-scoped fixture rather than at
8
- module import time, so test collection has no side effect on the filesystem.
9
- The sandbox is rooted in a per-session unique directory created via
10
- `tempfile.mkdtemp` so the OS-temp exemption (which the companion shares with
11
- the blocker) does not silently skip the hook during tests.
12
- """
13
-
14
- import functools
15
- import json
16
- import os
17
- import shutil
18
- import stat
19
- import subprocess
20
- import sys
21
- import tempfile
22
- from pathlib import Path
23
-
24
- import pytest
25
-
26
-
27
- HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "md_to_html_companion.py")
28
-
29
-
30
- def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
31
- try:
32
- os.chmod(target_path, stat.S_IWRITE)
33
- removal_function(target_path)
34
- except OSError:
35
- pass
36
-
37
-
38
- def _force_rmtree(target_path: str) -> None:
39
- handler_kw = (
40
- {"onexc": _strip_read_only_and_retry}
41
- if sys.version_info >= (3, 12)
42
- else {"onerror": _strip_read_only_and_retry}
43
- )
44
- try:
45
- shutil.rmtree(target_path, **handler_kw)
46
- except OSError:
47
- pass
48
-
49
-
50
- @functools.lru_cache(maxsize=1)
51
- def _get_sandbox_parent_directory() -> str:
52
- return tempfile.mkdtemp(prefix="pytest_md_companion_", dir=str(Path.home()))
53
-
54
-
55
- @pytest.fixture(scope="session", autouse=True)
56
- def _cleanup_sandbox_parent_directory():
57
- yield
58
- if _get_sandbox_parent_directory.cache_info().currsize:
59
- _force_rmtree(_get_sandbox_parent_directory())
60
- _get_sandbox_parent_directory.cache_clear()
61
-
62
-
63
- def _make_sandbox() -> tempfile.TemporaryDirectory:
64
- """Return a TemporaryDirectory rooted outside the OS temp directory.
65
-
66
- The companion exempts the OS temp directory (mirroring the blocker), so
67
- the default `tempfile.TemporaryDirectory()` would prevent the test hook
68
- invocation generating any HTML sidecar at all.
69
- """
70
- return tempfile.TemporaryDirectory(dir=_get_sandbox_parent_directory())
71
-
72
-
73
- class _RunHook:
74
- def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
75
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
76
- return subprocess.run(
77
- [sys.executable, HOOK_SCRIPT_PATH],
78
- input=payload,
79
- capture_output=True,
80
- text=True,
81
- check=False,
82
- )
83
-
84
-
85
- _run_hook = _RunHook()
86
-
87
-
88
- def test_generates_html_companion():
89
- with _make_sandbox() as tmp:
90
- md_path = os.path.join(tmp, "guide.md")
91
- html_path = os.path.join(tmp, "guide.html")
92
-
93
-
94
- with open(md_path, "w", encoding="utf-8") as f:
95
- f.write("# Hello\n\nThis is a test.")
96
-
97
- result = _run_hook(
98
- "Write", {"file_path": md_path, "content": "# Hello\n\nThis is a test."}
99
- )
100
- assert result.returncode == 0
101
- assert os.path.exists(html_path)
102
-
103
-
104
- def test_html_contains_heading():
105
- with _make_sandbox() as tmp:
106
- md_path = os.path.join(tmp, "guide.md")
107
- with open(md_path, "w", encoding="utf-8") as f:
108
- f.write("# Hello World")
109
-
110
- _run_hook("Write", {"file_path": md_path, "content": "# Hello World"})
111
- html_path = os.path.join(tmp, "guide.html")
112
- with open(html_path, encoding="utf-8") as f:
113
- html = f.read()
114
- assert "<h1>" in html
115
- assert "Hello World" in html
116
-
117
-
118
- def test_html_wraps_in_template():
119
- with _make_sandbox() as tmp:
120
- md_path = os.path.join(tmp, "guide.md")
121
- with open(md_path, "w", encoding="utf-8") as f:
122
- f.write("plain text")
123
-
124
- _run_hook("Write", {"file_path": md_path, "content": "plain text"})
125
- html_path = os.path.join(tmp, "guide.html")
126
- with open(html_path, encoding="utf-8") as f:
127
- html = f.read()
128
- assert "<!DOCTYPE html>" in html
129
- assert "<style>" in html
130
-
131
-
132
- def test_skips_non_md_files():
133
- with _make_sandbox() as tmp:
134
- py_path = os.path.join(tmp, "main.py")
135
- html_path = os.path.join(tmp, "main.html")
136
-
137
-
138
- with open(py_path, "w", encoding="utf-8") as f:
139
- f.write("x = 1")
140
-
141
- result = _run_hook("Write", {"file_path": py_path, "content": "x = 1"})
142
- assert result.returncode == 0
143
- assert not os.path.exists(html_path)
144
-
145
-
146
- def test_skips_claude_dir():
147
- with _make_sandbox() as tmp:
148
- claude_dir = os.path.join(tmp, ".claude")
149
- md_path = os.path.join(claude_dir, "CLAUDE.md")
150
- html_path = os.path.join(claude_dir, "CLAUDE.html")
151
-
152
- os.makedirs(claude_dir, exist_ok=True)
153
- with open(md_path, "w", encoding="utf-8") as f:
154
- f.write("# CLAUDE.md")
155
-
156
- result = _run_hook("Write", {"file_path": md_path, "content": "# CLAUDE.md"})
157
- assert result.returncode == 0
158
- assert not os.path.exists(html_path)
159
-
160
-
161
- def test_unknown_tool_passes():
162
- result = _run_hook("Grep", {"pattern": "foo"})
163
- assert result.returncode == 0
164
- assert result.stdout == ""
165
-
166
-
167
- def test_empty_file_path_passes():
168
- result = _run_hook("Write", {"file_path": "", "content": "# Hello"})
169
- assert result.returncode == 0
170
- assert result.stdout == ""
171
-
172
-
173
- def test_nonexistent_md_passes():
174
- result = _run_hook(
175
- "Write",
176
- {"file_path": "/nonexistent/path/guide.md", "content": "# Hello"},
177
- )
178
- assert result.returncode == 0
179
-
180
-
181
- def test_converts_code_fence():
182
- with _make_sandbox() as tmp:
183
- md_path = os.path.join(tmp, "guide.md")
184
- with open(md_path, "w", encoding="utf-8") as f:
185
- f.write("```python\nprint('hi')\n```")
186
-
187
- _run_hook(
188
- "Write", {"file_path": md_path, "content": "```python\nprint('hi')\n```"}
189
- )
190
- html_path = os.path.join(tmp, "guide.html")
191
- with open(html_path, encoding="utf-8") as f:
192
- html = f.read()
193
- assert "<pre>" in html
194
- assert "<code" in html
195
- assert "print(&#x27;hi&#x27;)" in html
196
-
197
-
198
- def test_converts_bold():
199
- with _make_sandbox() as tmp:
200
- md_path = os.path.join(tmp, "guide.md")
201
- with open(md_path, "w", encoding="utf-8") as f:
202
- f.write("This is **bold** text.")
203
-
204
- _run_hook("Write", {"file_path": md_path, "content": "This is **bold** text."})
205
- html_path = os.path.join(tmp, "guide.html")
206
- with open(html_path, encoding="utf-8") as f:
207
- html = f.read()
208
- assert "<strong>bold</strong>" in html
209
-
210
-
211
- def test_escapes_html_special_chars():
212
- with _make_sandbox() as tmp:
213
- md_path = os.path.join(tmp, "guide.md")
214
- with open(md_path, "w", encoding="utf-8") as f:
215
- f.write("Use <div> for layout & choose \"text\" for quotes.")
216
-
217
- _run_hook(
218
- "Write",
219
- {
220
- "file_path": md_path,
221
- "content": "Use <div> for layout & choose \"text\" for quotes.",
222
- },
223
- )
224
- html_path = os.path.join(tmp, "guide.html")
225
- with open(html_path, encoding="utf-8") as f:
226
- html = f.read()
227
- assert "&lt;div&gt;" in html
228
- assert "&amp;" in html
229
- assert "<div>" not in html
230
-
231
-
232
- def test_escapes_code_block_content():
233
- with _make_sandbox() as tmp:
234
- md_path = os.path.join(tmp, "guide.md")
235
- with open(md_path, "w", encoding="utf-8") as f:
236
- f.write("```\nif x < 5 and y > 3:\n print('hello')\n```")
237
-
238
- _run_hook(
239
- "Write",
240
- {
241
- "file_path": md_path,
242
- "content": "```\nif x < 5 and y > 3:\n print('hello')\n```",
243
- },
244
- )
245
- html_path = os.path.join(tmp, "guide.html")
246
- with open(html_path, encoding="utf-8") as f:
247
- html = f.read()
248
- assert "&lt;" in html
249
- assert "if x" in html
250
-
251
-
252
- def test_lists_are_wrapped_in_ul():
253
- with _make_sandbox() as tmp:
254
- md_path = os.path.join(tmp, "guide.md")
255
- with open(md_path, "w", encoding="utf-8") as f:
256
- f.write("- item one\n- item two\n- item three")
257
-
258
- _run_hook(
259
- "Write",
260
- {
261
- "file_path": md_path,
262
- "content": "- item one\n- item two\n- item three",
263
- },
264
- )
265
- html_path = os.path.join(tmp, "guide.html")
266
- with open(html_path, encoding="utf-8") as f:
267
- html = f.read()
268
- assert "<ul>" in html
269
- assert "</ul>" in html
270
- assert html.index("<ul>") < html.index("<li>item one</li>")
271
- assert html.index("</li>") < html.index("</ul>")
272
-
273
-
274
- def test_ordered_lists_are_wrapped_in_ol():
275
- with _make_sandbox() as tmp:
276
- md_path = os.path.join(tmp, "guide.md")
277
- with open(md_path, "w", encoding="utf-8") as f:
278
- f.write("1. first\n2. second")
279
-
280
- _run_hook(
281
- "Write",
282
- {"file_path": md_path, "content": "1. first\n2. second"},
283
- )
284
- html_path = os.path.join(tmp, "guide.html")
285
- with open(html_path, encoding="utf-8") as f:
286
- html = f.read()
287
- assert "<ol>" in html
288
- assert "</ol>" in html
289
-
290
-
291
- def test_handles_curly_braces_in_body():
292
- with _make_sandbox() as tmp:
293
- md_path = os.path.join(tmp, "guide.md")
294
- with open(md_path, "w", encoding="utf-8") as f:
295
- f.write("# JS Example\n\nUse `{ foo: 1 }` in code.")
296
-
297
- _run_hook(
298
- "Write",
299
- {
300
- "file_path": md_path,
301
- "content": "# JS Example\n\nUse `{ foo: 1 }` in code.",
302
- },
303
- )
304
- html_path = os.path.join(tmp, "guide.html")
305
- with open(html_path, encoding="utf-8") as f:
306
- html = f.read()
307
- assert "{ foo: 1 }" in html
308
- assert "{{" not in html
309
- assert "JS Example" in html
310
-
311
-
312
- def test_escapes_title_in_html_output():
313
- with _make_sandbox() as tmp:
314
- md_path = os.path.join(tmp, "guide.md")
315
- with open(md_path, "w", encoding="utf-8") as f:
316
- f.write("# Hackers <3 Markdown & <scripts>")
317
-
318
- _run_hook(
319
- "Write",
320
- {
321
- "file_path": md_path,
322
- "content": "# Hackers <3 Markdown & <scripts>",
323
- },
324
- )
325
- html_path = os.path.join(tmp, "guide.html")
326
- with open(html_path, encoding="utf-8") as f:
327
- html = f.read()
328
- assert "<title>Hackers &lt;3 Markdown &amp; &lt;scripts&gt;</title>" in html
329
- assert "<script>" not in html
330
-
331
-
332
- def test_skips_root_readme():
333
- with _make_sandbox() as tmp:
334
- Path(tmp, ".git").touch()
335
- original_cwd = os.getcwd()
336
- try:
337
- os.chdir(tmp)
338
- for each_name in ("README.md", "readme.md"):
339
- with open(each_name, "w", encoding="utf-8") as f:
340
- f.write("# Test")
341
- result = _run_hook(
342
- "Write", {"file_path": each_name, "content": "# Test"}
343
- )
344
- assert result.returncode == 0
345
- expected_html = each_name.replace(".md", ".html")
346
- assert not os.path.exists(expected_html)
347
- finally:
348
- os.chdir(original_cwd)
349
-
350
-
351
- def test_skips_root_changelog():
352
- with _make_sandbox() as tmp:
353
- Path(tmp, ".git").touch()
354
- original_cwd = os.getcwd()
355
- try:
356
- os.chdir(tmp)
357
- for each_name in ("CHANGELOG.md", "changelog.md"):
358
- with open(each_name, "w", encoding="utf-8") as f:
359
- f.write("# Test")
360
- result = _run_hook(
361
- "Write", {"file_path": each_name, "content": "# Test"}
362
- )
363
- assert result.returncode == 0
364
- expected_html = each_name.replace(".md", ".html")
365
- assert not os.path.exists(expected_html)
366
- finally:
367
- os.chdir(original_cwd)
368
-
369
-
370
- def test_language_class_valid():
371
- with _make_sandbox() as tmp:
372
- md_path = os.path.join(tmp, "guide.md")
373
- with open(md_path, "w", encoding="utf-8") as f:
374
- f.write("```python\nx = 1\n```")
375
-
376
- _run_hook("Write", {"file_path": md_path, "content": "```python\nx = 1\n```"})
377
- html_path = os.path.join(tmp, "guide.html")
378
- with open(html_path, encoding="utf-8") as f:
379
- html = f.read()
380
- assert 'class="language-python"' in html
381
-
382
-
383
- def test_language_class_skips_invalid():
384
- with _make_sandbox() as tmp:
385
- md_path = os.path.join(tmp, "guide.md")
386
- with open(md_path, "w", encoding="utf-8") as f:
387
- f.write("```my lang\nx = 1\n```")
388
-
389
- _run_hook("Write", {"file_path": md_path, "content": "```my lang\nx = 1\n```"})
390
- html_path = os.path.join(tmp, "guide.html")
391
- with open(html_path, encoding="utf-8") as f:
392
- html = f.read()
393
- assert "<pre><code>" in html
394
- assert 'class="language-' not in html
395
-
396
-
397
- def test_language_class_allows_valid_chars():
398
- with _make_sandbox() as tmp:
399
- md_path = os.path.join(tmp, "guide.md")
400
- with open(md_path, "w", encoding="utf-8") as f:
401
- f.write("```c++\nint x = 1;\n```")
402
-
403
- _run_hook("Write", {"file_path": md_path, "content": "```c++\nint x = 1;\n```"})
404
- html_path = os.path.join(tmp, "guide.html")
405
- with open(html_path, encoding="utf-8") as f:
406
- html = f.read()
407
- assert 'class="language-c++"' in html
408
-
409
-
410
- def test_link_text_asterisks_remain_literal():
411
- with _make_sandbox() as tmp:
412
- md_path = os.path.join(tmp, "guide.md")
413
- with open(md_path, "w", encoding="utf-8") as f:
414
- f.write("See [text *not italic*](url).")
415
-
416
- _run_hook(
417
- "Write",
418
- {"file_path": md_path, "content": "See [text *not italic*](url)."},
419
- )
420
- html_path = os.path.join(tmp, "guide.html")
421
- with open(html_path, encoding="utf-8") as f:
422
- html = f.read()
423
- assert '<a href="url">text *not italic*</a>' in html
424
- assert "<em>" not in html
425
-
426
-
427
- def test_handles_parentheses_in_links():
428
- with _make_sandbox() as tmp:
429
- md_path = os.path.join(tmp, "guide.md")
430
- with open(md_path, "w", encoding="utf-8") as f:
431
- f.write(
432
- "See [Python]"
433
- "(https://en.wikipedia.org/wiki/Python_(programming_language))."
434
- )
435
-
436
- _run_hook(
437
- "Write",
438
- {
439
- "file_path": md_path,
440
- "content": "See [Python]"
441
- "(https://en.wikipedia.org/wiki/Python_(programming_language)).",
442
- },
443
- )
444
- html_path = os.path.join(tmp, "guide.html")
445
- with open(html_path, encoding="utf-8") as f:
446
- html = f.read()
447
- assert (
448
- 'href="https://en.wikipedia.org/wiki/Python_(programming_language)"'
449
- in html
450
- )
451
-
452
-
453
- def test_does_not_skip_nested_readme():
454
- with _make_sandbox() as tmp:
455
- nested_dir = os.path.join(tmp, "docs")
456
- os.makedirs(nested_dir)
457
- md_path = os.path.join(nested_dir, "README.md")
458
- html_path = os.path.join(nested_dir, "README.html")
459
-
460
- with open(md_path, "w", encoding="utf-8") as f:
461
- f.write("# Nested README")
462
-
463
- result = _run_hook(
464
- "Write",
465
- {"file_path": md_path, "content": "# Nested README"},
466
- )
467
- assert result.returncode == 0
468
- assert os.path.exists(html_path)
469
-
470
-
471
- def test_inline_code_preserves_asterisks():
472
- with _make_sandbox() as tmp:
473
- md_path = os.path.join(tmp, "guide.md")
474
- with open(md_path, "w", encoding="utf-8") as f:
475
- f.write("Type `**bold**` in a docstring.")
476
-
477
- _run_hook(
478
- "Write",
479
- {
480
- "file_path": md_path,
481
- "content": "Type `**bold**` in a docstring.",
482
- },
483
- )
484
- html_path = os.path.join(tmp, "guide.html")
485
- with open(html_path, encoding="utf-8") as f:
486
- html = f.read()
487
- assert "<code>**bold**</code>" in html
488
- assert "<strong>" not in html
489
-
490
-
491
- def test_blocks_javascript_url_scheme():
492
- with _make_sandbox() as tmp:
493
- md_path = os.path.join(tmp, "guide.md")
494
- with open(md_path, "w", encoding="utf-8") as f:
495
- f.write("[click me](javascript:alert(1))")
496
-
497
- _run_hook(
498
- "Write",
499
- {
500
- "file_path": md_path,
501
- "content": "[click me](javascript:alert(1))",
502
- },
503
- )
504
- html_path = os.path.join(tmp, "guide.html")
505
- with open(html_path, encoding="utf-8") as f:
506
- html = f.read()
507
- assert "javascript:" not in html
508
- assert "click me" in html
509
- assert "<a" not in html
510
-
511
-
512
- def test_companion_skips_home_session_log_directory(tmp_path, monkeypatch):
513
- synthetic_home_directory = tmp_path / "synthetic_home"
514
- synthetic_home_directory.mkdir()
515
- monkeypatch.setenv("HOME", str(synthetic_home_directory))
516
- monkeypatch.setenv("USERPROFILE", str(synthetic_home_directory))
517
- session_log_directory = synthetic_home_directory / "SessionLog" / "decisions"
518
- session_log_directory.mkdir(parents=True)
519
- md_path = str(session_log_directory / "companion_exempt_test.md")
520
- html_path = str(session_log_directory / "companion_exempt_test.html")
521
- with open(md_path, "w", encoding="utf-8") as f:
522
- f.write("# Note")
523
- result = _run_hook(
524
- "Write",
525
- {"file_path": md_path, "content": "# Note"},
526
- )
527
- assert result.returncode == 0
528
- assert not os.path.exists(html_path)
529
-
530
-
531
- def test_companion_skips_skill_md_anywhere():
532
- with _make_sandbox() as tmp:
533
- nested_directory = os.path.join(tmp, "packages", "dev-env", "skills", "foo")
534
- os.makedirs(nested_directory, exist_ok=True)
535
- md_path = os.path.join(nested_directory, "SKILL.md")
536
- html_path = os.path.join(nested_directory, "SKILL.html")
537
- with open(md_path, "w", encoding="utf-8") as f:
538
- f.write("# Skill")
539
- result = _run_hook(
540
- "Write",
541
- {"file_path": md_path, "content": "# Skill"},
542
- )
543
- assert result.returncode == 0
544
- assert not os.path.exists(html_path)
545
-
546
-
547
- def test_companion_skips_agents_directory_anywhere():
548
- with _make_sandbox() as tmp:
549
- agents_directory = os.path.join(tmp, "packages", "dev-env", "agents")
550
- os.makedirs(agents_directory, exist_ok=True)
551
- md_path = os.path.join(agents_directory, "pr-description-writer.md")
552
- html_path = os.path.join(
553
- agents_directory, "pr-description-writer.html"
554
- )
555
- with open(md_path, "w", encoding="utf-8") as f:
556
- f.write("# Agent")
557
- result = _run_hook(
558
- "Write",
559
- {"file_path": md_path, "content": "# Agent"},
560
- )
561
- assert result.returncode == 0
562
- assert not os.path.exists(html_path)
563
-
564
-
565
- def test_companion_skips_claude_plugin_directory():
566
- with _make_sandbox() as tmp:
567
- plugin_directory = os.path.join(tmp, ".claude-plugin")
568
- os.makedirs(plugin_directory, exist_ok=True)
569
- md_path = os.path.join(plugin_directory, "manifest.md")
570
- html_path = os.path.join(plugin_directory, "manifest.html")
571
- with open(md_path, "w", encoding="utf-8") as f:
572
- f.write("# Manifest")
573
- result = _run_hook(
574
- "Write",
575
- {"file_path": md_path, "content": "# Manifest"},
576
- )
577
- assert result.returncode == 0
578
- assert not os.path.exists(html_path)
579
-
580
-
581
- def test_companion_still_fires_for_ordinary_docs_md_file():
582
- with _make_sandbox() as tmp:
583
- docs_directory = os.path.join(tmp, "docs")
584
- os.makedirs(docs_directory, exist_ok=True)
585
- md_path = os.path.join(docs_directory, "regular.md")
586
- html_path = os.path.join(docs_directory, "regular.html")
587
- with open(md_path, "w", encoding="utf-8") as f:
588
- f.write("# Regular")
589
- result = _run_hook(
590
- "Write",
591
- {"file_path": md_path, "content": "# Regular"},
592
- )
593
- assert result.returncode == 0
594
- assert os.path.exists(html_path)
595
-
596
-
597
- def test_companion_skips_system_temp_directory():
598
- temp_directory = tempfile.gettempdir()
599
- md_path = os.path.join(temp_directory, "companion_temp_exempt_test.md")
600
- html_path = os.path.join(temp_directory, "companion_temp_exempt_test.html")
601
- try:
602
- with open(md_path, "w", encoding="utf-8") as f:
603
- f.write("# Scratch")
604
- result = _run_hook(
605
- "Write",
606
- {"file_path": md_path, "content": "# Scratch"},
607
- )
608
- assert result.returncode == 0
609
- assert not os.path.exists(html_path)
610
- finally:
611
- for each_path in (md_path, html_path):
612
- if os.path.exists(each_path):
613
- os.remove(each_path)