claude-dev-env 1.41.0 → 1.43.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 (214) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +232 -8
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +293 -8
  4. package/_shared/pr-loop/scripts/fix_hookspath.py +96 -5
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +124 -20
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +4 -4
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +90 -0
  8. package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_settings_keys_constants.py +2 -0
  9. package/_shared/pr-loop/scripts/preflight.py +13 -31
  10. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -16
  11. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +76 -33
  12. package/_shared/pr-loop/scripts/tests/conftest.py +1 -51
  13. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  14. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +4 -2
  15. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +37 -2
  16. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +4 -2
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +4 -2
  18. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +6 -2
  19. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +2 -2
  20. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1 -2
  21. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +4 -2
  22. package/_shared/pr-loop/scripts/tests/test_preflight.py +17 -52
  23. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +6 -2
  24. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +5 -3
  25. package/agents/pr-description-writer.md +50 -140
  26. package/docs/PR_DESCRIPTION_GUIDE.md +101 -102
  27. package/hooks/_gh_pr_author_swap_utils.py +1 -1
  28. package/hooks/blocking/bot_mention_comment_blocker.py +4 -10
  29. package/hooks/blocking/code_rules_enforcer.py +217 -99
  30. package/hooks/blocking/code_rules_path_utils.py +8 -1
  31. package/hooks/blocking/destructive_command_blocker.py +1 -1
  32. package/hooks/blocking/es_exe_path_rewriter.py +7 -13
  33. package/hooks/blocking/gh_body_arg_blocker.py +6 -1
  34. package/hooks/blocking/gh_pr_author_enforcer.py +5 -5
  35. package/hooks/blocking/gh_pr_author_restore.py +5 -5
  36. package/hooks/blocking/hedging_language_blocker.py +4 -10
  37. package/hooks/blocking/md_path_exemptions.py +205 -0
  38. package/hooks/blocking/md_to_html_blocker.py +48 -20
  39. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -11
  40. package/hooks/blocking/pr_description_enforcer.py +626 -41
  41. package/hooks/blocking/question_to_user_enforcer.py +4 -10
  42. package/hooks/blocking/state_description_blocker.py +6 -12
  43. package/hooks/blocking/tdd_enforcer.py +1 -1
  44. package/hooks/blocking/test_bot_mention_comment_blocker.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer.py +3 -3
  46. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +1 -1
  47. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -2
  48. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +184 -0
  49. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +82 -0
  50. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +29 -29
  51. package/hooks/blocking/test_gh_body_arg_blocker.py +7 -8
  52. package/hooks/blocking/test_gh_pr_author_enforcer.py +1 -1
  53. package/hooks/blocking/test_gh_pr_author_restore.py +1 -1
  54. package/hooks/blocking/test_hedging_language_blocker.py +2 -2
  55. package/hooks/blocking/test_md_to_html_blocker.py +463 -8
  56. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +1 -1
  57. package/hooks/blocking/test_pr_description_enforcer.py +1210 -13
  58. package/hooks/blocking/test_question_to_user_enforcer.py +1 -1
  59. package/hooks/blocking/windows_rmtree_blocker.py +5 -11
  60. package/hooks/diagnostic/hook_log_extractor.py +1 -1
  61. package/hooks/diagnostic/hook_log_init.py +1 -1
  62. package/hooks/diagnostic/hook_log_stop_wrapper.py +1 -1
  63. package/hooks/diagnostic/test_hook_log_extractor.py +1 -1
  64. package/hooks/diagnostic/test_hook_log_init.py +2 -2
  65. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +1 -1
  66. package/hooks/git-hooks/gate_utils.py +1 -1
  67. package/hooks/git-hooks/pre_commit.py +1 -1
  68. package/hooks/git-hooks/pre_push.py +1 -1
  69. package/hooks/git-hooks/test_config.py +5 -5
  70. package/hooks/git-hooks/test_pre_push.py +6 -6
  71. package/hooks/{config → hooks_constants}/code_rules_enforcer_constants.py +37 -0
  72. package/hooks/hooks_constants/code_rules_path_utils_constants.py +28 -0
  73. package/hooks/hooks_constants/md_to_html_blocker_constants.py +82 -0
  74. package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_state.py +1 -1
  75. package/hooks/hooks_constants/pr_description_enforcer_constants.py +154 -0
  76. package/hooks/{config → hooks_constants}/pre_tool_use_stdin.py +1 -1
  77. package/hooks/{config → hooks_constants}/project_paths_reader.py +2 -2
  78. package/hooks/{config → hooks_constants}/test_banned_identifiers_constants.py +1 -1
  79. package/hooks/{config → hooks_constants}/test_dynamic_stderr_handler.py +1 -1
  80. package/hooks/{config → hooks_constants}/test_hardcoded_user_path_constants.py +1 -1
  81. package/hooks/{config → hooks_constants}/test_hook_log_extractor_constants.py +2 -2
  82. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +110 -0
  83. package/hooks/{config → hooks_constants}/test_messages.py +2 -6
  84. package/hooks/{config → hooks_constants}/test_path_rewriter_constants.py +1 -1
  85. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +292 -0
  86. package/hooks/{config → hooks_constants}/test_pre_tool_use_stdin.py +2 -2
  87. package/hooks/{config → hooks_constants}/test_project_paths_reader.py +3 -3
  88. package/hooks/{config → hooks_constants}/test_session_env_cleanup_constants.py +1 -1
  89. package/hooks/{config → hooks_constants}/test_setup_project_paths_constants.py +2 -2
  90. package/hooks/{config → hooks_constants}/test_unused_module_import_constants.py +1 -1
  91. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +5 -11
  92. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +1 -1
  93. package/hooks/session/gh_pr_author_session_cleanup.py +5 -6
  94. package/hooks/session/session_env_cleanup.py +4 -10
  95. package/hooks/session/test_gh_pr_author_session_cleanup.py +1 -1
  96. package/hooks/session/test_untracked_repo_detector.py +2 -2
  97. package/hooks/session/untracked_repo_detector.py +6 -12
  98. package/hooks/test__gh_pr_author_swap_utils.py +1 -1
  99. package/hooks/validators/run_all_validators.py +16 -5
  100. package/hooks/validators/test_output_formatter.py +46 -0
  101. package/hooks/workflow/doc_gist_auto_publish.py +1 -1
  102. package/hooks/workflow/md_to_html_companion.py +8 -15
  103. package/hooks/workflow/test_md_to_html_companion.py +184 -23
  104. package/package.json +1 -1
  105. package/rules/ask-user-question-required.md +1 -1
  106. package/rules/vault-context.md +1 -1
  107. package/scripts/{config → dev_env_scripts_constants}/timing.py +1 -1
  108. package/scripts/setup_project_paths.py +49 -11
  109. package/scripts/sweep_empty_dirs.py +10 -1
  110. package/scripts/test_setup_project_paths.py +2 -2
  111. package/scripts/test_sweep_empty_dirs.py +2 -6
  112. package/skills/_shared/pr-loop/scripts/_path_resolver.py +1 -1
  113. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  114. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  115. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -1
  116. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -1
  117. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  118. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  119. package/skills/bugteam/PROMPTS.md +1 -1
  120. package/skills/bugteam/SKILL.md +1 -1
  121. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  122. package/skills/bugteam/scripts/{_claude_permissions_common.py → _bugteam_permissions_common.py} +110 -13
  123. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +1 -13
  124. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +1 -16
  125. package/skills/bugteam/scripts/bugteam_preflight.py +1 -13
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +69 -0
  127. package/skills/bugteam/scripts/grant_project_claude_permissions.py +117 -12
  128. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +1 -1
  129. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +71 -25
  131. package/skills/bugteam/scripts/{test__claude_permissions_common.py → test__bugteam_permissions_common.py} +4 -4
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  133. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -26
  134. package/skills/bugteam/scripts/{test_claude_permissions_common.py → test_bugteam_permissions_common.py} +3 -66
  135. package/skills/bugteam/scripts/test_bugteam_preflight.py +2 -27
  136. package/skills/bugteam/scripts/windows_safe_rmtree.py +1 -1
  137. package/skills/doc-gist/SKILL.md +1 -1
  138. package/skills/doc-gist/scripts/gist_upload.py +1 -1
  139. package/skills/implement/SKILL.md +66 -0
  140. package/skills/implement/scripts/append_note.py +133 -0
  141. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  142. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +12 -0
  143. package/skills/implement/scripts/test_append_note.py +191 -0
  144. package/skills/pr-converge/pr_converge_skill_constants/__init__.py +0 -0
  145. package/skills/pr-converge/{config → pr_converge_skill_constants}/constants.py +6 -1
  146. package/skills/pr-converge/scripts/check_bugbot_ci.py +2 -2
  147. package/skills/pr-converge/scripts/check_convergence.py +175 -29
  148. package/skills/pr-converge/scripts/check_pending_reviews.py +2 -2
  149. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +2 -2
  150. package/skills/pr-converge/scripts/post_fix_reply.py +2 -2
  151. package/skills/pr-converge/scripts/pr_converge_scripts_constants/__init__.py +0 -0
  152. package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/pr_converge_constants.py +1 -1
  153. package/skills/pr-converge/scripts/reflow_skill_md.py +90 -16
  154. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  155. package/skills/pr-converge/scripts/test_check_convergence.py +324 -0
  156. package/skills/pr-converge/scripts/test_reflow_skill_md.py +0 -31
  157. package/skills/refine/SKILL.md +257 -0
  158. package/skills/refine/templates/implementation-notes-template.html +56 -0
  159. package/skills/refine/templates/plan-template.md +60 -0
  160. package/skills/session-log/SKILL.md +98 -233
  161. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +0 -36
  162. package/hooks/config/pr_description_enforcer_constants.py +0 -19
  163. package/hooks/config/test_pr_description_enforcer_constants.py +0 -82
  164. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +0 -20
  165. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +0 -55
  166. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +0 -55
  167. package/skills/pr-converge/scripts/evict_cached_config_modules.py +0 -20
  168. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +0 -22
  169. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/__init__.py +0 -0
  170. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/code_rules_gate_constants.py +0 -0
  171. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/fix_hookspath_constants.py +0 -0
  172. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/post_audit_thread_constants.py +0 -0
  173. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/preflight_constants.py +0 -0
  174. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/reviews_disabled_constants.py +0 -0
  175. /package/hooks/git-hooks/{config.py → git_hooks_constants/__init__.py} +0 -0
  176. /package/hooks/{config → hooks_constants}/__init__.py +0 -0
  177. /package/hooks/{config → hooks_constants}/any_type_config.py +0 -0
  178. /package/hooks/{config → hooks_constants}/banned_identifiers_constants.py +0 -0
  179. /package/hooks/{config → hooks_constants}/blocking_check_limits.py +0 -0
  180. /package/hooks/{config → hooks_constants}/bot_mention_comment_blocker_constants.py +0 -0
  181. /package/hooks/{config → hooks_constants}/convergence_branch_constants.py +0 -0
  182. /package/hooks/{config → hooks_constants}/doc_gist_auto_publish_constants.py +0 -0
  183. /package/hooks/{config → hooks_constants}/dynamic_stderr_handler.py +0 -0
  184. /package/hooks/{config → hooks_constants}/gh_pr_author_swap_constants.py +0 -0
  185. /package/hooks/{config → hooks_constants}/hardcoded_user_path_constants.py +0 -0
  186. /package/hooks/{config → hooks_constants}/hook_log_extractor_constants.py +0 -0
  187. /package/hooks/{config → hooks_constants}/html_companion_constants.py +0 -0
  188. /package/hooks/{config → hooks_constants}/inline_tuple_string_magic_constants.py +0 -0
  189. /package/hooks/{config → hooks_constants}/messages.py +0 -0
  190. /package/hooks/{config → hooks_constants}/path_rewriter_constants.py +0 -0
  191. /package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_constants.py +0 -0
  192. /package/hooks/{config → hooks_constants}/session_env_cleanup_constants.py +0 -0
  193. /package/hooks/{config → hooks_constants}/setup_project_paths_constants.py +0 -0
  194. /package/hooks/{config → hooks_constants}/state_description_blocker_constants.py +0 -0
  195. /package/hooks/{config → hooks_constants}/stuttering_check_config.py +0 -0
  196. /package/hooks/{config → hooks_constants}/stuttering_import_binding_constants.py +0 -0
  197. /package/hooks/{config → hooks_constants}/sys_path_insert_constants.py +0 -0
  198. /package/hooks/{config → hooks_constants}/unused_module_import_constants.py +0 -0
  199. /package/hooks/{config → hooks_constants}/windows_rmtree_blocker_constants.py +0 -0
  200. /package/{skills/_shared/pr-loop/scripts/config → hooks/lifecycle}/__init__.py +0 -0
  201. /package/{skills/bugteam/scripts/config → hooks/session}/__init__.py +0 -0
  202. /package/scripts/{config → dev_env_scripts_constants}/__init__.py +0 -0
  203. /package/skills/{doc-gist/scripts/config → _shared/pr-loop/scripts/skills_pr_loop_constants}/__init__.py +0 -0
  204. /package/skills/_shared/pr-loop/scripts/{config → skills_pr_loop_constants}/path_resolver_constants.py +0 -0
  205. /package/skills/{pr-converge/config → bugteam/scripts/bugteam_scripts_constants}/__init__.py +0 -0
  206. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_code_rules_gate_constants.py +0 -0
  207. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_fix_hookspath_constants.py +0 -0
  208. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_preflight_constants.py +0 -0
  209. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/probe_code_rules_enforcer_check_constants.py +0 -0
  210. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/reflow_skill_md_constants.py +0 -0
  211. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/windows_safe_rmtree_constants.py +0 -0
  212. /package/skills/{pr-converge/scripts/config → doc-gist/scripts/doc_gist_scripts_constants}/__init__.py +0 -0
  213. /package/skills/doc-gist/scripts/{config → doc_gist_scripts_constants}/gist_upload_constants.py +0 -0
  214. /package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/reflow_skill_md_constants.py +0 -0
@@ -15,11 +15,11 @@ import subprocess
15
15
  import sys
16
16
  from pathlib import Path
17
17
 
18
- _pr_converge_dir = Path(__file__).resolve().parent.parent
18
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
19
19
  if str(_pr_converge_dir) not in sys.path:
20
20
  sys.path.insert(0, str(_pr_converge_dir))
21
21
 
22
- from config.constants import (
22
+ from pr_converge_skill_constants.constants import (
23
23
  COPILOT_LOGIN_FILTER_SUBSTRING,
24
24
  GH_REVIEWS_PATH_TEMPLATE,
25
25
  REVIEWS_PER_PAGE,
@@ -20,11 +20,11 @@ import subprocess
20
20
  import sys
21
21
  from pathlib import Path
22
22
 
23
- _pr_converge_dir = Path(__file__).resolve().parent.parent
23
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
24
24
  if str(_pr_converge_dir) not in sys.path:
25
25
  sys.path.insert(0, str(_pr_converge_dir))
26
26
 
27
- from config.constants import (
27
+ from pr_converge_skill_constants.constants import (
28
28
  EXIT_CODE_GH_ERROR,
29
29
  GH_INLINE_COMMENT_REPLY_PATH_TEMPLATE,
30
30
  GH_ISSUE_COMMENT_CREATE_PATH_TEMPLATE,
@@ -11,7 +11,7 @@ settings) live here.
11
11
  import re
12
12
  from pathlib import Path
13
13
 
14
- from config.constants import ( # noqa: F401
14
+ from pr_converge_skill_constants.constants import ( # noqa: F401
15
15
  ALL_BUGBOT_CHECK_RUN_ACTIVE_STATUSES,
16
16
  ALL_CLAUDE_DIRTY_REVIEW_STATES,
17
17
  ALL_COPILOT_DIRTY_REVIEW_STATES,
@@ -9,22 +9,9 @@ Run: python3 packages/claude-dev-env/skills/pr-converge/scripts/reflow_skill_md.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- import sys
13
12
  import textwrap
14
- from pathlib import Path
15
13
 
16
- script_directory = str(Path(__file__).resolve().parent)
17
-
18
- while script_directory in sys.path:
19
- sys.path.remove(script_directory)
20
- if script_directory not in sys.path:
21
- sys.path.insert(0, script_directory)
22
-
23
- from evict_cached_config_modules import evict_cached_config_modules
24
-
25
- evict_cached_config_modules()
26
-
27
- from config.reflow_skill_md_constants import (
14
+ from pr_converge_scripts_constants.reflow_skill_md_constants import (
28
15
  BASH_CONTINUATION_MARKER_WIDTH,
29
16
  BULLET_LIST_ITEM_PATTERN as BULLET_RE,
30
17
  MARKDOWN_REFERENCE_DEFINITION_PATTERN as REF_DEF_RE,
@@ -36,6 +23,14 @@ from config.reflow_skill_md_constants import (
36
23
 
37
24
 
38
25
  def wrap_paragraph_plain(text: str) -> list[str]:
26
+ """Wrap a plain paragraph to MAX_WIDTH after collapsing whitespace.
27
+
28
+ Args:
29
+ text: Paragraph text with internal whitespace runs to collapse.
30
+
31
+ Returns:
32
+ Wrapped lines; empty list when the input collapses to nothing.
33
+ """
39
34
  collapsed = " ".join(text.split())
40
35
  if not collapsed:
41
36
  return []
@@ -48,6 +43,17 @@ def wrap_paragraph_plain(text: str) -> list[str]:
48
43
 
49
44
 
50
45
  def wrap_list_item(lead_ws: str, marker: str, body: str) -> list[str]:
46
+ """Wrap a list item, preserving the leading marker and indentation.
47
+
48
+ Args:
49
+ lead_ws: Leading whitespace before the list marker.
50
+ marker: List marker such as a hyphen or numeric prefix.
51
+ body: Item body text to wrap.
52
+
53
+ Returns:
54
+ Wrapped lines with the marker on the first line and matching
55
+ indentation on subsequent lines.
56
+ """
51
57
  collapsed = " ".join(body.split())
52
58
  if not collapsed:
53
59
  return [lead_ws + marker.rstrip()]
@@ -67,6 +73,16 @@ def reflow_yaml_description_block(
67
73
  all_lines: list[str],
68
74
  body_start: int,
69
75
  ) -> tuple[list[str], int]:
76
+ """Reflow the YAML description block until the closing fence.
77
+
78
+ Args:
79
+ all_lines: Full SKILL.md lines.
80
+ body_start: Index of the first description body line.
81
+
82
+ Returns:
83
+ Tuple of wrapped description lines and the index just past the
84
+ closing fence.
85
+ """
70
86
  body_parts: list[str] = []
71
87
  index = body_start
72
88
  while index < len(all_lines):
@@ -95,6 +111,16 @@ def is_table_line(line: str) -> bool:
95
111
 
96
112
 
97
113
  def is_new_logical_line(stripped: str) -> bool:
114
+ """Decide whether ``stripped`` starts a new logical line.
115
+
116
+ Args:
117
+ stripped: Candidate line with leading whitespace already removed.
118
+
119
+ Returns:
120
+ True when the line begins a new markdown construct (fence, heading,
121
+ table row, list item, reference definition, or example tag) and
122
+ therefore must not be merged into the prior buffer.
123
+ """
98
124
  if not stripped:
99
125
  return False
100
126
  if stripped.startswith("```"):
@@ -115,7 +141,16 @@ def is_new_logical_line(stripped: str) -> bool:
115
141
 
116
142
 
117
143
  def merge_without_space(buffer: str, continuation: str) -> bool:
118
- """Join without space only for split markdown link URL paths."""
144
+ """Join without space only for split markdown link URL paths.
145
+
146
+ Args:
147
+ buffer: Accumulated line preceding the candidate continuation.
148
+ continuation: Next line to evaluate for joining.
149
+
150
+ Returns:
151
+ True when continuation is the tail of a split markdown link target
152
+ and buffer ends inside an unfinished link target.
153
+ """
119
154
  base = buffer.rstrip()
120
155
  stripped = continuation.lstrip()
121
156
  if not base or not stripped:
@@ -126,6 +161,15 @@ def merge_without_space(buffer: str, continuation: str) -> bool:
126
161
 
127
162
 
128
163
  def merge_soft_breaks(all_lines: list[str]) -> list[str]:
164
+ """Merge soft line breaks across non-fence markdown paragraphs.
165
+
166
+ Args:
167
+ all_lines: Raw SKILL.md lines.
168
+
169
+ Returns:
170
+ Lines with each paragraph collapsed to a single buffer line; fences
171
+ and blank lines are preserved verbatim.
172
+ """
129
173
  reflowed_lines: list[str] = []
130
174
  index = 0
131
175
  is_inside_fence = False
@@ -166,6 +210,15 @@ def merge_soft_breaks(all_lines: list[str]) -> list[str]:
166
210
 
167
211
 
168
212
  def reflow_merged_line(line: str) -> list[str]:
213
+ """Reflow a single merged buffer line into MAX_WIDTH-bounded lines.
214
+
215
+ Args:
216
+ line: Buffer line produced by merge_soft_breaks.
217
+
218
+ Returns:
219
+ Wrapped lines; structural constructs (fences, tables, separators)
220
+ are returned unchanged.
221
+ """
169
222
  stripped = line.strip()
170
223
  if stripped == "":
171
224
  return [""]
@@ -221,6 +274,14 @@ def reflow_merged_line(line: str) -> list[str]:
221
274
 
222
275
 
223
276
  def reflow_markdown_body(all_lines: list[str]) -> list[str]:
277
+ """Merge soft breaks then reflow every line of the SKILL.md body.
278
+
279
+ Args:
280
+ all_lines: Raw SKILL.md body lines following the YAML front matter.
281
+
282
+ Returns:
283
+ Reflowed body lines bounded by MAX_WIDTH.
284
+ """
224
285
  merged = merge_soft_breaks(all_lines)
225
286
  reflowed_lines: list[str] = []
226
287
  for each_line in merged:
@@ -232,7 +293,15 @@ def reflow_markdown_body(all_lines: list[str]) -> list[str]:
232
293
 
233
294
 
234
295
  def wrap_long_bash_fence_lines(all_lines: list[str]) -> list[str]:
235
- """Hard-wrap only ```bash fence bodies that still exceed MAX_WIDTH."""
296
+ """Hard-wrap bash fence bodies that still exceed MAX_WIDTH.
297
+
298
+ Args:
299
+ all_lines: SKILL.md body lines after paragraph reflow.
300
+
301
+ Returns:
302
+ Lines with overlong bash-fence bodies split on whitespace with a
303
+ trailing backslash continuation marker.
304
+ """
236
305
  wrapped_lines: list[str] = []
237
306
  is_inside_bash_fence = False
238
307
  for each_line in all_lines:
@@ -272,6 +341,11 @@ def wrap_long_bash_fence_lines(all_lines: list[str]) -> list[str]:
272
341
 
273
342
 
274
343
  def main() -> None:
344
+ """Read SKILL.md, reflow it to MAX_WIDTH, and write the result back.
345
+
346
+ Raises:
347
+ SystemExit: When the file does not start with YAML front matter.
348
+ """
275
349
  raw = SKILL_PATH.read_text(encoding="utf-8")
276
350
  lines = raw.splitlines()
277
351
  if not lines or lines[0].strip() != "---":
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock, patch
21
21
 
22
22
  import pytest
23
23
 
24
- _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
24
+ _SCRIPTS_DIRECTORY = Path(__file__).absolute().parent
25
25
 
26
26
 
27
27
  @pytest.fixture(scope="session")
@@ -0,0 +1,324 @@
1
+ """Tests for check_convergence.
2
+
3
+ Covers the bugteam audit gate (`_check_bugteam_clean`) which identifies
4
+ bugteam reviews by body header signature rather than by the posting user's
5
+ GitHub login. Three scenarios are exercised:
6
+
7
+ - a clean bugteam review on the current HEAD passes the gate
8
+ - a dirty bugteam review on the current HEAD fails the gate
9
+ - the absence of any bugteam review on the current HEAD fails the gate
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+ from types import ModuleType
19
+ from typing import Callable
20
+
21
+ import pytest
22
+
23
+ _SCRIPTS_DIRECTORY = Path(__file__).absolute().parent
24
+ _PR_CONVERGE_DIRECTORY = _SCRIPTS_DIRECTORY.parent
25
+
26
+ if str(_PR_CONVERGE_DIRECTORY) not in sys.path:
27
+ sys.path.insert(0, str(_PR_CONVERGE_DIRECTORY))
28
+
29
+
30
+ def _load_module() -> ModuleType:
31
+ for each_cached_name in [
32
+ each_key
33
+ for each_key in list(sys.modules)
34
+ if each_key == "config" or each_key.startswith("config.")
35
+ ]:
36
+ sys.modules.pop(each_cached_name, None)
37
+ if str(_PR_CONVERGE_DIRECTORY) in sys.path:
38
+ sys.path.remove(str(_PR_CONVERGE_DIRECTORY))
39
+ sys.path.insert(0, str(_PR_CONVERGE_DIRECTORY))
40
+ module_path = _SCRIPTS_DIRECTORY / "check_convergence.py"
41
+ spec = importlib.util.spec_from_file_location(
42
+ "check_convergence_under_test", module_path
43
+ )
44
+ assert spec is not None
45
+ assert spec.loader is not None
46
+ module = importlib.util.module_from_spec(spec)
47
+ spec.loader.exec_module(module)
48
+ return module
49
+
50
+
51
+ check_convergence = _load_module()
52
+
53
+ CURRENT_HEAD_SHA = "abcdef1234567890abcdef1234567890abcdef12"
54
+ OTHER_HEAD_SHA = "0000000000000000000000000000000000000000"
55
+ CLEAN_BUGTEAM_BODY = (
56
+ "**Bugteam audit completed** —— Clean — no findings\n"
57
+ "\n"
58
+ "---\n"
59
+ "### Audit pass clean\n"
60
+ "\n"
61
+ "The Bugteam audit pass against commit `abcdef1` found no findings.\n"
62
+ )
63
+ DIRTY_BUGTEAM_BODY = (
64
+ "**Bugteam audit completed** —— Findings requested\n"
65
+ "\n"
66
+ "---\n"
67
+ "### Findings recorded as inline review comments\n"
68
+ "\n"
69
+ "The Bugteam audit pass against commit `abcdef1` surfaced 2 finding(s).\n"
70
+ )
71
+ NON_BUGTEAM_BODY = (
72
+ "Cursor Bugbot has reviewed your changes and found 0 potential issues."
73
+ )
74
+
75
+
76
+ def _make_stub_gh_paginated(
77
+ all_review_objects: list[dict[str, object]],
78
+ ) -> Callable[[str], tuple[int, str]]:
79
+ pages_payload = [all_review_objects]
80
+ serialized = json.dumps(pages_payload)
81
+
82
+ def stub_gh_api_paginated(endpoint_path: str) -> tuple[int, str]:
83
+ return 0, serialized
84
+
85
+ return stub_gh_api_paginated
86
+
87
+
88
+ def should_pass_when_clean_bugteam_review_present_on_current_head(
89
+ monkeypatch: pytest.MonkeyPatch,
90
+ ) -> None:
91
+ reviews_payload = [
92
+ {
93
+ "id": 1001,
94
+ "body": CLEAN_BUGTEAM_BODY,
95
+ "commit_id": CURRENT_HEAD_SHA,
96
+ "submitted_at": "2026-05-17T12:00:00Z",
97
+ }
98
+ ]
99
+ stub = _make_stub_gh_paginated(reviews_payload)
100
+ monkeypatch.setattr(check_convergence, "_gh_api_paginated", stub)
101
+ passed, detail = check_convergence._check_bugteam_clean(
102
+ owner="JonEcho",
103
+ repo="tests",
104
+ number=42,
105
+ head_sha=CURRENT_HEAD_SHA,
106
+ )
107
+ assert passed is True
108
+ assert "clean bugteam audit" in detail
109
+ assert CURRENT_HEAD_SHA[:7] in detail
110
+
111
+
112
+ def should_fail_when_dirty_bugteam_review_present_on_current_head(
113
+ monkeypatch: pytest.MonkeyPatch,
114
+ ) -> None:
115
+ reviews_payload = [
116
+ {
117
+ "id": 1002,
118
+ "body": DIRTY_BUGTEAM_BODY,
119
+ "commit_id": CURRENT_HEAD_SHA,
120
+ "submitted_at": "2026-05-17T12:00:00Z",
121
+ }
122
+ ]
123
+ stub = _make_stub_gh_paginated(reviews_payload)
124
+ monkeypatch.setattr(check_convergence, "_gh_api_paginated", stub)
125
+ passed, detail = check_convergence._check_bugteam_clean(
126
+ owner="JonEcho",
127
+ repo="tests",
128
+ number=42,
129
+ head_sha=CURRENT_HEAD_SHA,
130
+ )
131
+ assert passed is False
132
+ assert "dirty bugteam audit" in detail
133
+ assert CURRENT_HEAD_SHA[:7] in detail
134
+
135
+
136
+ def should_fail_when_no_bugteam_review_present_on_current_head(
137
+ monkeypatch: pytest.MonkeyPatch,
138
+ ) -> None:
139
+ reviews_payload = [
140
+ {
141
+ "id": 1003,
142
+ "body": NON_BUGTEAM_BODY,
143
+ "commit_id": CURRENT_HEAD_SHA,
144
+ "submitted_at": "2026-05-17T12:00:00Z",
145
+ },
146
+ {
147
+ "id": 1004,
148
+ "body": CLEAN_BUGTEAM_BODY,
149
+ "commit_id": OTHER_HEAD_SHA,
150
+ "submitted_at": "2026-05-17T11:00:00Z",
151
+ },
152
+ ]
153
+ stub = _make_stub_gh_paginated(reviews_payload)
154
+ monkeypatch.setattr(check_convergence, "_gh_api_paginated", stub)
155
+ passed, detail = check_convergence._check_bugteam_clean(
156
+ owner="JonEcho",
157
+ repo="tests",
158
+ number=42,
159
+ head_sha=CURRENT_HEAD_SHA,
160
+ )
161
+ assert passed is False
162
+ assert "no bugteam review found" in detail
163
+
164
+
165
+ def should_fail_with_shape_detail_when_gh_returns_non_list_payload(
166
+ monkeypatch: pytest.MonkeyPatch,
167
+ ) -> None:
168
+ error_object_payload = {"message": "Not Found", "documentation_url": "https://docs.github.com/rest"}
169
+ serialized_error = json.dumps(error_object_payload)
170
+
171
+ def stub_gh_api_paginated_returning_object(endpoint_path: str) -> tuple[int, str]:
172
+ return 0, serialized_error
173
+
174
+ monkeypatch.setattr(
175
+ check_convergence, "_gh_api_paginated", stub_gh_api_paginated_returning_object
176
+ )
177
+ passed, detail = check_convergence._check_bugteam_clean(
178
+ owner="JonEcho",
179
+ repo="tests",
180
+ number=42,
181
+ head_sha=CURRENT_HEAD_SHA,
182
+ )
183
+ assert passed is False
184
+ assert "unexpected gh api response shape" in detail
185
+
186
+
187
+ def test_private_helpers_recognize_clean_new_header_body() -> None:
188
+ assert check_convergence._is_bugteam_review(CLEAN_BUGTEAM_BODY) is True
189
+ assert check_convergence._is_clean_bugteam_review(CLEAN_BUGTEAM_BODY) is True
190
+
191
+
192
+ def test_private_helpers_recognize_dirty_new_header_body() -> None:
193
+ assert check_convergence._is_bugteam_review(DIRTY_BUGTEAM_BODY) is True
194
+ assert check_convergence._is_clean_bugteam_review(DIRTY_BUGTEAM_BODY) is False
195
+
196
+
197
+ def test_private_helpers_reject_non_bugteam_body() -> None:
198
+ assert check_convergence._is_bugteam_review(NON_BUGTEAM_BODY) is False
199
+ assert check_convergence._is_clean_bugteam_review(NON_BUGTEAM_BODY) is False
200
+
201
+
202
+ CLEAN_LEGACY_BUGTEAM_BODY = (
203
+ "## /bugteam loop 1 audit: 0 P0 / 0 P1 / 0 P2 → clean"
204
+ )
205
+ DIRTY_LEGACY_BUGTEAM_BODY = (
206
+ "## /bugteam loop 1 audit: 1 P0 / 0 P1 / 0 P2 → dirty"
207
+ )
208
+
209
+
210
+ def test_private_helpers_recognize_clean_legacy_header_body() -> None:
211
+ assert check_convergence._is_bugteam_review(CLEAN_LEGACY_BUGTEAM_BODY) is True
212
+ assert check_convergence._is_clean_bugteam_review(CLEAN_LEGACY_BUGTEAM_BODY) is True
213
+
214
+
215
+ def test_private_helpers_recognize_dirty_legacy_header_body() -> None:
216
+ assert check_convergence._is_bugteam_review(DIRTY_LEGACY_BUGTEAM_BODY) is True
217
+ assert check_convergence._is_clean_bugteam_review(DIRTY_LEGACY_BUGTEAM_BODY) is False
218
+
219
+
220
+ def should_bypass_bugbot_gates_when_bugbot_down_is_true(
221
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
222
+ ) -> None:
223
+ all_invocation_names: list[str] = []
224
+
225
+ def stub_get_pr_head_sha(*, owner: str, repo: str, number: int) -> str:
226
+ all_invocation_names.append("_get_pr_head_sha")
227
+ return CURRENT_HEAD_SHA
228
+
229
+ def stub_check_bugbot_should_not_be_called(
230
+ *, owner: str, repo: str, sha: str
231
+ ) -> tuple[bool, str]:
232
+ all_invocation_names.append("_check_bugbot")
233
+ raise AssertionError("_check_bugbot must not be invoked when bugbot_down=True")
234
+
235
+ def stub_check_bugbot_not_dirty_should_not_be_called(
236
+ *, owner: str, repo: str, number: int, head_sha: str
237
+ ) -> tuple[bool, str]:
238
+ all_invocation_names.append("_check_bugbot_not_dirty")
239
+ raise AssertionError(
240
+ "_check_bugbot_not_dirty must not be invoked when bugbot_down=True"
241
+ )
242
+
243
+ def stub_check_bugteam_clean(
244
+ *, owner: str, repo: str, number: int, head_sha: str
245
+ ) -> tuple[bool, str]:
246
+ all_invocation_names.append("_check_bugteam_clean")
247
+ return True, "stub passing"
248
+
249
+ def stub_check_bot_review(
250
+ *,
251
+ owner: str,
252
+ repo: str,
253
+ number: int,
254
+ head_sha: str,
255
+ login_substring: str,
256
+ clean_states: tuple[str, ...],
257
+ dirty_states: tuple[str, ...],
258
+ label: str,
259
+ ) -> tuple[bool, str]:
260
+ all_invocation_names.append("_check_bot_review")
261
+ return True, "stub passing"
262
+
263
+ def stub_count_unresolved_bot_threads(
264
+ *, owner: str, repo: str, number: int
265
+ ) -> tuple[bool, str]:
266
+ all_invocation_names.append("_count_unresolved_bot_threads")
267
+ return True, "stub passing"
268
+
269
+ def stub_get_mergeable(
270
+ *, owner: str, repo: str, number: int
271
+ ) -> tuple[bool, str]:
272
+ all_invocation_names.append("_get_mergeable")
273
+ return True, "stub passing"
274
+
275
+ def stub_check_no_pending_reviews(
276
+ *, owner: str, repo: str, number: int
277
+ ) -> tuple[bool, str]:
278
+ all_invocation_names.append("_check_no_pending_reviews")
279
+ return True, "stub passing"
280
+
281
+ monkeypatch.setattr(check_convergence, "_get_pr_head_sha", stub_get_pr_head_sha)
282
+ monkeypatch.setattr(check_convergence, "_check_bugbot", stub_check_bugbot_should_not_be_called)
283
+ monkeypatch.setattr(
284
+ check_convergence,
285
+ "_check_bugbot_not_dirty",
286
+ stub_check_bugbot_not_dirty_should_not_be_called,
287
+ )
288
+ monkeypatch.setattr(check_convergence, "_check_bugteam_clean", stub_check_bugteam_clean)
289
+ monkeypatch.setattr(check_convergence, "_check_bot_review", stub_check_bot_review)
290
+ monkeypatch.setattr(
291
+ check_convergence, "_count_unresolved_bot_threads", stub_count_unresolved_bot_threads
292
+ )
293
+ monkeypatch.setattr(check_convergence, "_get_mergeable", stub_get_mergeable)
294
+ monkeypatch.setattr(
295
+ check_convergence, "_check_no_pending_reviews", stub_check_no_pending_reviews
296
+ )
297
+
298
+ exit_code = check_convergence.check_all(
299
+ owner="o", repo="r", number=1, bugbot_down=True
300
+ )
301
+ captured_stdout = capsys.readouterr().out
302
+
303
+ assert "_check_bugbot" not in all_invocation_names
304
+ assert "_check_bugbot_not_dirty" not in all_invocation_names
305
+ assert "bypassed (bugbot_down)" in captured_stdout
306
+ assert exit_code == 0
307
+
308
+
309
+ def should_propagate_systemexit_from_get_pr_head_sha(
310
+ monkeypatch: pytest.MonkeyPatch,
311
+ ) -> None:
312
+ def stub_get_pr_head_sha_raising_systemexit(
313
+ *, owner: str, repo: str, number: int
314
+ ) -> str:
315
+ raise SystemExit(check_convergence.EXIT_CODE_GH_ERROR)
316
+
317
+ monkeypatch.setattr(
318
+ check_convergence, "_get_pr_head_sha", stub_get_pr_head_sha_raising_systemexit
319
+ )
320
+
321
+ with pytest.raises(SystemExit) as exc_info:
322
+ check_convergence.check_all(owner="o", repo="r", number=1, bugbot_down=False)
323
+
324
+ assert exc_info.value.code == check_convergence.EXIT_CODE_GH_ERROR
@@ -108,27 +108,6 @@ def test_reflow_merged_line_preserves_long_markdown_reference_definition() -> No
108
108
  assert reflow_module.reflow_merged_line(line) == [stripped_line]
109
109
 
110
110
 
111
- def test_reflow_bootstrap_moves_script_directory_ahead_of_shadow_config(
112
- tmp_path: Path,
113
- ) -> None:
114
- """sys.path bootstrap must move the script directory ahead of shadow config packages."""
115
- shadow_config_directory = tmp_path / "shadow" / "config"
116
- shadow_config_directory.mkdir(parents=True)
117
- (shadow_config_directory / "__init__.py").write_text("", encoding="utf-8")
118
- (shadow_config_directory / "pr_converge_constants.py").write_text(
119
- "BROKEN = True\n", encoding="utf-8"
120
- )
121
- original_sys_path = list(sys.path)
122
- try:
123
- sys.path.insert(0, str(tmp_path / "shadow"))
124
- loaded_module = _load_module()
125
- assert loaded_module.MAX_WIDTH == 80
126
- assert sys.path[0] == str(_SCRIPTS_DIRECTORY)
127
- assert sys.path.count(str(_SCRIPTS_DIRECTORY)) == 1
128
- finally:
129
- sys.path[:] = original_sys_path
130
-
131
-
132
111
  def test_wrap_long_bash_fence_lines_uses_continuation_marker_for_long_lines() -> None:
133
112
  """Wrapped continuation lines use the bash continuation marker."""
134
113
  long_line = "echo " + "word " * 20
@@ -150,13 +129,3 @@ def test_reflow_uses_config_constant_for_continuation_marker_width() -> None:
150
129
  "reflow_skill_md.py must import BASH_CONTINUATION_MARKER_WIDTH from config"
151
130
  )
152
131
 
153
- def test_reflow_bootstrap_matches_code_rules_sys_path_pattern() -> None:
154
- """Bootstrap must guard insert with a membership check."""
155
- module_path = _SCRIPTS_DIRECTORY / "reflow_skill_md.py"
156
- source = module_path.read_text(encoding="utf-8")
157
- assert "while script_directory in sys.path:" in source, (
158
- "Bootstrap must dedup script_directory entries before insert"
159
- )
160
- assert "sys.path.insert(0, script_directory)" in source, (
161
- "Bootstrap must insert script_directory at index 0"
162
- )