claude-dev-env 1.82.0 → 1.84.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 (217) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/audit-contract.md +24 -12
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/README.md +1 -0
  5. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  9. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  10. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  11. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  12. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  13. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  14. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  15. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  18. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  19. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  20. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  21. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  22. package/agents/clean-coder.md +3 -0
  23. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  24. package/bin/install.mjs +16 -8
  25. package/bin/install.test.mjs +17 -6
  26. package/commands/CLAUDE.md +0 -1
  27. package/docs/CODE_RULES.md +1 -1
  28. package/hooks/blocking/CLAUDE.md +2 -4
  29. package/hooks/blocking/code_rules_constants_config.py +164 -1
  30. package/hooks/blocking/code_rules_docstrings.py +428 -15
  31. package/hooks/blocking/code_rules_enforcer.py +33 -0
  32. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  33. package/hooks/blocking/code_rules_magic_values.py +5 -0
  34. package/hooks/blocking/code_rules_naming_collection.py +152 -6
  35. package/hooks/blocking/code_rules_shared.py +34 -0
  36. package/hooks/blocking/code_rules_string_magic.py +68 -0
  37. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  38. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  39. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  40. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  41. package/hooks/blocking/state_description_blocker.py +96 -5
  42. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  45. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  46. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  47. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  48. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  49. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  50. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  53. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  54. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  55. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  56. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  57. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  58. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  60. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  61. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  63. package/hooks/blocking/test_state_description_blocker.py +135 -0
  64. package/hooks/hooks.json +5 -0
  65. package/hooks/hooks_constants/CLAUDE.md +3 -4
  66. package/hooks/hooks_constants/blocking_check_limits.py +46 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  68. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  70. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  71. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  72. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  73. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  74. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  75. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  76. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  77. package/hooks/workflow/CLAUDE.md +2 -8
  78. package/package.json +1 -1
  79. package/rules/CLAUDE.md +5 -2
  80. package/rules/claude-md-orphan-file.md +5 -0
  81. package/rules/docstring-prose-matches-implementation.md +10 -1
  82. package/rules/env-var-table-code-drift.md +5 -0
  83. package/rules/es-exe-file-search.md +17 -0
  84. package/rules/no-historical-clutter.md +12 -1
  85. package/rules/orphan-css-class.md +5 -0
  86. package/rules/package-inventory-stale-entry.md +10 -0
  87. package/rules/paired-test-coverage.md +5 -0
  88. package/rules/plain-illustrative-docstrings.md +40 -1
  89. package/rules/verify-before-asking.md +7 -0
  90. package/rules/verify-runtime-state.md +40 -0
  91. package/rules/windows-filesystem-safe.md +8 -0
  92. package/rules/workers-done-before-complete.md +33 -0
  93. package/rules/workflow-substitution-slots.md +5 -0
  94. package/skills/CLAUDE.md +7 -2
  95. package/skills/autoconverge/SKILL.md +48 -61
  96. package/skills/autoconverge/reference/closing-report.md +6 -6
  97. package/skills/autoconverge/reference/convergence.md +17 -15
  98. package/skills/autoconverge/reference/gotchas.md +6 -3
  99. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  100. package/skills/autoconverge/workflow/converge.contract.test.mjs +153 -10
  101. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +55 -19
  102. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  103. package/skills/autoconverge/workflow/converge.mjs +167 -106
  104. package/skills/autoconverge/workflow/render_report.py +7 -11
  105. package/skills/bugteam/CLAUDE.md +1 -1
  106. package/skills/bugteam/PROMPTS.md +7 -6
  107. package/skills/bugteam/SKILL.md +25 -72
  108. package/skills/bugteam/reference/CLAUDE.md +1 -3
  109. package/skills/bugteam/reference/README.md +1 -1
  110. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  111. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  112. package/skills/bugteam/reference/team-setup.md +8 -7
  113. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  114. package/skills/bugteam/scripts/README.md +0 -4
  115. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  116. package/skills/code/SKILL.md +2 -0
  117. package/skills/copilot-review/CLAUDE.md +1 -1
  118. package/skills/copilot-review/SKILL.md +25 -23
  119. package/skills/everything-search/SKILL.md +5 -0
  120. package/skills/findbugs/CLAUDE.md +2 -2
  121. package/skills/findbugs/SKILL.md +22 -83
  122. package/skills/fixbugs/SKILL.md +2 -4
  123. package/skills/log-audit/CLAUDE.md +20 -0
  124. package/skills/log-audit/SKILL.md +68 -0
  125. package/skills/log-audit/reference/CLAUDE.md +9 -0
  126. package/skills/log-audit/reference/charter.md +52 -0
  127. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  128. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  129. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  130. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  131. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  132. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  133. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  134. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  135. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  136. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  137. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  138. package/skills/monitor-open-prs/SKILL.md +2 -2
  139. package/skills/post-audit-findings/SKILL.md +84 -0
  140. package/skills/pr-converge/CLAUDE.md +2 -0
  141. package/skills/pr-converge/SKILL.md +72 -59
  142. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  143. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  144. package/skills/pr-converge/reference/examples.md +5 -5
  145. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  146. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  147. package/skills/pr-converge/reference/per-tick.md +24 -45
  148. package/skills/pr-converge/reference/state-schema.md +15 -0
  149. package/skills/pr-converge/scripts/README.md +3 -5
  150. package/skills/pr-fix-protocol/SKILL.md +70 -0
  151. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  152. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  153. package/skills/pr-scope-resolve/SKILL.md +48 -0
  154. package/skills/qbug/CLAUDE.md +4 -4
  155. package/skills/qbug/SKILL.md +46 -144
  156. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  157. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  158. package/skills/reviewer-gates/SKILL.md +96 -0
  159. package/skills/session-log/CLAUDE.md +7 -7
  160. package/skills/session-log/SKILL.md +27 -44
  161. package/skills/test_markdown_link_integrity.py +103 -0
  162. package/commands/doc-gist.md +0 -16
  163. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  164. package/hooks/blocking/conftest.py +0 -30
  165. package/hooks/blocking/md_path_exemptions.py +0 -224
  166. package/hooks/blocking/md_to_html_blocker.py +0 -155
  167. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  168. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  169. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  170. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  171. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  172. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  173. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  174. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  175. package/hooks/workflow/md_to_html_companion.py +0 -358
  176. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  177. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  178. package/skills/bugteam/reference/audit-contract.md +0 -163
  179. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  180. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  181. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  182. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  183. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  184. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  185. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  186. package/skills/doc-gist/CLAUDE.md +0 -25
  187. package/skills/doc-gist/SKILL.md +0 -97
  188. package/skills/doc-gist/references/CLAUDE.md +0 -9
  189. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  190. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  191. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  192. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  193. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  194. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  195. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  196. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  197. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  198. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  199. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  200. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  201. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  202. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  203. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  204. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  205. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  206. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  207. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  208. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  209. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  210. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  211. package/skills/doc-gist/references/examples/README.md +0 -5
  212. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  213. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  214. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  215. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  216. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  217. /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)