claude-dev-env 1.38.1 → 1.40.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 (282) hide show
  1. package/CLAUDE.md +10 -36
  2. package/_shared/pr-loop/audit-reply-template.md +147 -0
  3. package/_shared/pr-loop/fix-protocol.md +25 -4
  4. package/_shared/pr-loop/gh-payloads.md +37 -50
  5. package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
  6. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +199 -0
  7. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  8. package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
  9. package/_shared/pr-loop/scripts/preflight.py +129 -2
  10. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  12. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
  13. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  14. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  15. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  16. package/_shared/pr-loop/state-schema.md +1 -1
  17. package/agents/clean-coder.md +2 -2
  18. package/agents/pr-description-writer.md +150 -52
  19. package/bin/install.mjs +6 -7
  20. package/bin/install.test.mjs +8 -0
  21. package/commands/doc-gist.md +16 -0
  22. package/commands/plan.md +0 -2
  23. package/commands/review-plan.md +1 -1
  24. package/docs/CODE_RULES.md +122 -2
  25. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  26. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  27. package/hooks/blocking/code_rules_enforcer.py +1143 -129
  28. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  29. package/hooks/blocking/destructive_command_blocker.py +74 -0
  30. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  31. package/hooks/blocking/md_to_html_blocker.py +119 -0
  32. package/hooks/blocking/pr_description_enforcer.py +57 -22
  33. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  34. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  35. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  36. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  37. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  38. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  39. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  40. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  41. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  42. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  44. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  45. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  46. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  47. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  48. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  49. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  50. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  51. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  52. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  53. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  54. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  55. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  56. package/hooks/config/any_type_config.py +7 -0
  57. package/hooks/config/banned_identifiers_constants.py +11 -0
  58. package/hooks/config/blocking_check_limits.py +38 -0
  59. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  60. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  61. package/hooks/config/convergence_branch_constants.py +9 -0
  62. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  63. package/hooks/config/html_companion_constants.py +20 -0
  64. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  65. package/hooks/config/pr_description_enforcer_constants.py +14 -0
  66. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  67. package/hooks/hooks.json +28 -20
  68. package/hooks/pyproject.toml +69 -0
  69. package/hooks/validators/mypy_integration.py +47 -1
  70. package/hooks/validators/run_all_validators.py +3 -3
  71. package/hooks/validators/test_mypy_integration.py +50 -1
  72. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  73. package/hooks/workflow/md_to_html_companion.py +365 -0
  74. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  75. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  76. package/package.json +1 -1
  77. package/rules/gh-body-file.md +2 -0
  78. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  79. package/scripts/check.ps1 +106 -0
  80. package/scripts/config/timing.py +11 -0
  81. package/scripts/sweep_empty_dirs.py +138 -0
  82. package/scripts/sync_to_cursor/rules.py +1 -1
  83. package/scripts/test_sweep_empty_dirs.py +183 -0
  84. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  85. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  86. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  87. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  88. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  89. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  90. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  91. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  92. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  93. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  94. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  95. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  96. package/skills/bugteam/CONSTRAINTS.md +21 -22
  97. package/skills/bugteam/EXAMPLES.md +3 -3
  98. package/skills/bugteam/PROMPTS.md +227 -67
  99. package/skills/bugteam/SKILL.md +132 -455
  100. package/skills/bugteam/reference/README.md +1 -1
  101. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  102. package/skills/bugteam/reference/audit-contract.md +4 -22
  103. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  104. package/skills/bugteam/reference/design-rationale.md +2 -2
  105. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  106. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  107. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  108. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  109. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  112. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  113. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  114. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  115. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  116. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  117. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  118. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  119. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  120. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  121. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  122. package/skills/bugteam/reference/team-setup.md +111 -9
  123. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  124. package/skills/bugteam/scripts/README.md +60 -0
  125. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  126. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  127. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  128. package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
  129. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  130. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  131. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  132. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  133. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  134. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  135. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  136. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  137. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  138. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  139. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  140. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  141. package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
  142. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  143. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  144. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  145. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  146. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  147. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  148. package/skills/bugteam/test_skill_additions.py +1 -11
  149. package/skills/code/SKILL.md +176 -0
  150. package/skills/copilot-review/SKILL.md +16 -0
  151. package/skills/doc-gist/SKILL.md +99 -0
  152. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  153. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  154. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  155. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  156. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  157. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  158. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  159. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  160. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  161. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  162. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  163. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  164. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  165. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  166. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  167. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  168. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  169. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  170. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  171. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  172. package/skills/doc-gist/references/examples/README.md +5 -0
  173. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  174. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  175. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  176. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  177. package/skills/findbugs/SKILL.md +96 -2
  178. package/skills/monitor-open-prs/SKILL.md +14 -32
  179. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  180. package/skills/pr-consistency-audit/SKILL.md +112 -0
  181. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  182. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  183. package/skills/pr-converge/SKILL.md +229 -23
  184. package/skills/pr-converge/config/__init__.py +0 -0
  185. package/skills/pr-converge/config/constants.py +63 -0
  186. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  187. package/skills/pr-converge/reference/examples.md +43 -11
  188. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  189. package/skills/pr-converge/reference/ground-rules.md +5 -3
  190. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  191. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  192. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  193. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  194. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  195. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  196. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  197. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  198. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  199. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  200. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  201. package/skills/pr-converge/reference/per-tick.md +107 -31
  202. package/skills/pr-converge/reference/state-schema.md +22 -1
  203. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  204. package/skills/pr-converge/scripts/README.md +34 -46
  205. package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
  206. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  207. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  208. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  209. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  210. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  211. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  212. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  213. package/skills/qbug/SKILL.md +157 -27
  214. package/skills/session-log/SKILL.md +216 -114
  215. package/skills/session-tidy/SKILL.md +1 -1
  216. package/skills/skill-builder/SKILL.md +138 -56
  217. package/skills/skill-builder/references/delegation-map.md +72 -113
  218. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  219. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  220. package/skills/skill-builder/references/skill-types.md +228 -0
  221. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  222. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  223. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  224. package/skills/skill-builder/workflows/new-skill.md +80 -168
  225. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  226. package/skills/structure-prompt/SKILL.md +50 -0
  227. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  228. package/skills/structure-prompt/reference/block-classification.md +27 -0
  229. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  230. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  231. package/skills/structure-prompt/reference/cleanup.md +33 -0
  232. package/skills/structure-prompt/reference/constraints.md +33 -0
  233. package/skills/structure-prompt/reference/directives.md +37 -0
  234. package/skills/structure-prompt/reference/examples.md +72 -0
  235. package/skills/structure-prompt/reference/instantiation.md +51 -0
  236. package/skills/structure-prompt/reference/output-contract.md +72 -0
  237. package/skills/structure-prompt/reference/per-category.md +23 -0
  238. package/skills/structure-prompt/reference/persona.md +38 -0
  239. package/skills/structure-prompt/reference/research.md +33 -0
  240. package/skills/structure-prompt/reference/structure.md +28 -0
  241. package/agents/code-standards-agent.md +0 -93
  242. package/agents/groq-coder.md +0 -113
  243. package/agents/plan-executor.md +0 -226
  244. package/agents/project-docs-analyzer.md +0 -53
  245. package/agents/project-structure-organizer-agent.md +0 -72
  246. package/agents/skill-to-agent-converter.md +0 -370
  247. package/agents/skill-writer-agent.md +0 -470
  248. package/agents/user-docs-writer.md +0 -67
  249. package/agents/workflow-visual-documenter.md +0 -82
  250. package/commands/readability-review.md +0 -20
  251. package/hooks/mypy.ini +0 -2
  252. package/hooks/notification/attention_needed_notify.py +0 -71
  253. package/hooks/notification/claude_notification_handler.py +0 -67
  254. package/hooks/notification/notification_utils.py +0 -267
  255. package/hooks/notification/subagent_complete_notify.py +0 -381
  256. package/hooks/notification/test_attention_needed_notify.py +0 -47
  257. package/hooks/notification/test_claude_notification_handler.py +0 -54
  258. package/hooks/notification/test_notification_utils.py +0 -91
  259. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  260. package/scripts/config/groq_bugteam_config.py +0 -230
  261. package/scripts/config/test_groq_bugteam_config.py +0 -83
  262. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  263. package/scripts/groq_bugteam.README.md +0 -131
  264. package/scripts/groq_bugteam.py +0 -647
  265. package/scripts/groq_bugteam_dotenv.py +0 -40
  266. package/scripts/groq_bugteam_spec.py +0 -226
  267. package/scripts/test_groq_bugteam.py +0 -529
  268. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  269. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  270. package/scripts/test_groq_bugteam_spec.py +0 -338
  271. package/skills/bugteam/SKILL_EVALS.md +0 -309
  272. package/skills/dream/SKILL.md +0 -118
  273. package/skills/ingest/SKILL.md +0 -40
  274. package/skills/npm-creator/SKILL.md +0 -187
  275. package/skills/readability-review/SKILL.md +0 -127
  276. package/skills/resume-review/SKILL.md +0 -261
  277. package/skills/rule-audit/SKILL.md +0 -307
  278. package/skills/rule-creator/SKILL.md +0 -150
  279. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  280. package/skills/skill-writer/REFERENCE.md +0 -284
  281. package/skills/skill-writer/SKILL.md +0 -222
  282. package/skills/tdd-team/SKILL.md +0 -128
@@ -0,0 +1,112 @@
1
+ """TDD-pair tests for the underscore-prefixed _claude_permissions_common module.
2
+
3
+ The TDD enforcer matches a production filename ``X.py`` to ``test_X.py``;
4
+ ``_claude_permissions_common.py`` carries a leading underscore that the
5
+ enforcer treats as part of the name. This file's tests are the canonical
6
+ match. The broader behavioral suite continues to live alongside, in
7
+ ``test_claude_permissions_common.py``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from unittest.mock import patch
14
+
15
+ import pytest
16
+
17
+ _script_directory = str(Path(__file__).resolve().parent)
18
+ if _script_directory not in sys.path:
19
+ sys.path.insert(0, _script_directory)
20
+
21
+ import _claude_permissions_common as common_module
22
+
23
+
24
+ def test_write_atomically_with_mode_releases_fd_when_fdopen_raises(
25
+ tmp_path: Path,
26
+ ) -> None:
27
+ """A failure inside os.fdopen must close the raw file descriptor."""
28
+ target_path = tmp_path / "settings.json.tmp"
29
+ with patch.object(
30
+ common_module.os, "fdopen", side_effect=MemoryError("fdopen failure")
31
+ ):
32
+ with pytest.raises(MemoryError):
33
+ common_module.write_atomically_with_mode(
34
+ target_path, "payload", file_mode=0o600
35
+ )
36
+
37
+
38
+ def test_get_mode_to_preserve_returns_existing_file_mode(
39
+ tmp_path: Path,
40
+ ) -> None:
41
+ """When the file exists, the actual filesystem mode must be returned (not the default)."""
42
+ target_path = tmp_path / "settings.json"
43
+ target_path.write_text("{}", encoding="utf-8")
44
+ actual_filesystem_mode = target_path.stat().st_mode & 0o777
45
+ returned_mode = common_module.get_mode_to_preserve(target_path)
46
+ assert returned_mode == actual_filesystem_mode
47
+
48
+
49
+ def test_write_atomically_with_mode_raises_oserror_when_open_fails(
50
+ tmp_path: Path,
51
+ ) -> None:
52
+ """OSError from os.open must propagate (no fd leak path to test here)."""
53
+ target_path = tmp_path / "subdirectory" / "missing" / "settings.json.tmp"
54
+ with pytest.raises(OSError):
55
+ common_module.write_atomically_with_mode(
56
+ target_path, "payload", file_mode=0o600
57
+ )
58
+
59
+
60
+ def test_write_atomically_unlinks_temp_when_fdopen_raises(
61
+ tmp_path: Path,
62
+ ) -> None:
63
+ """A failure inside os.fdopen must remove the on-disk temp file.
64
+
65
+ Regression for loop1-9: the file existed the moment os.open returned,
66
+ but the OSError/MemoryError handler only closed the raw FD — leaving an
67
+ empty .tmp sibling on disk after the exception propagated up to
68
+ save_settings, where the unlink in the finally block was skipped because
69
+ `is_temp_owned_by_this_invocation` had not yet been set.
70
+ """
71
+ target_path = tmp_path / "settings.json.tmp"
72
+ with patch.object(
73
+ common_module.os, "fdopen", side_effect=MemoryError("fdopen failure")
74
+ ):
75
+ with pytest.raises(MemoryError):
76
+ common_module.write_atomically_with_mode(
77
+ target_path, "payload", file_mode=0o600
78
+ )
79
+ assert not target_path.exists(), (
80
+ "the temp file created by os.open must be unlinked before re-raising"
81
+ )
82
+
83
+
84
+ def test_save_settings_uses_pid_keyed_temp_suffix(tmp_path: Path) -> None:
85
+ """Concurrent save_settings invocations must not race on a deterministic temp name.
86
+
87
+ Regression for loop1-10: building the temp path as `settings.json.tmp` is
88
+ deterministic and unkeyed by PID, so two parallel /bugteam invocations
89
+ racing on Step 0 (grant) or Step 5 (revoke) collide on O_CREAT|O_EXCL —
90
+ the second caller hits FileExistsError and the OSError handler hard-exits,
91
+ silently dropping that PR's permission grant.
92
+ """
93
+ settings_path = tmp_path / "settings.json"
94
+ settings_path.write_text("{}", encoding="utf-8")
95
+ captured_temp_paths: list[str] = []
96
+ real_open = common_module.os.open
97
+
98
+ def capturing_open(target: str, *args: object, **kwargs: object) -> int:
99
+ captured_temp_paths.append(target)
100
+ return real_open(target, *args, **kwargs)
101
+
102
+ with patch.object(common_module.os, "open", side_effect=capturing_open):
103
+ common_module.save_settings(settings_path, {"first": True})
104
+ common_module.save_settings(settings_path, {"second": True})
105
+ assert len(captured_temp_paths) == 2
106
+ assert all(
107
+ str(common_module.os.getpid()) in each_temp_path
108
+ for each_temp_path in captured_temp_paths
109
+ ), (
110
+ "temp filename must include os.getpid() so concurrent runs do not "
111
+ f"collide on a deterministic name; saw: {captured_temp_paths!r}"
112
+ )
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ import unittest.mock
6
+ from pathlib import Path
7
+ import pytest
8
+
9
+ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
10
+ if str(SCRIPT_DIRECTORY) not in sys.path:
11
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
12
+
13
+ import bugteam_code_rules_gate as gate_module
14
+
15
+
16
+ def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
17
+ completion = subprocess.run(
18
+ ["git", *arguments],
19
+ cwd=str(repository_root),
20
+ capture_output=True,
21
+ text=True,
22
+ encoding="utf-8",
23
+ errors="replace",
24
+ check=True,
25
+ )
26
+ return completion.stdout
27
+
28
+
29
+ def initialize_git_repository(repository_root: Path) -> None:
30
+ run_git_in_repository(repository_root, "init")
31
+ run_git_in_repository(repository_root, "symbolic-ref", "HEAD", "refs/heads/main")
32
+ run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
33
+ run_git_in_repository(repository_root, "config", "user.name", "Test")
34
+ run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
35
+
36
+
37
+ def commit_all_files(repository_root: Path, commit_message: str) -> None:
38
+ run_git_in_repository(repository_root, "add", "-A")
39
+ run_git_in_repository(repository_root, "commit", "-m", commit_message)
40
+
41
+
42
+ def write_file(file_path: Path, content: str) -> None:
43
+ file_path.parent.mkdir(parents=True, exist_ok=True)
44
+ file_path.write_text(content, encoding="utf-8")
45
+
46
+
47
+ def stage_file(repository_root: Path, relative_path: str) -> None:
48
+ run_git_in_repository(repository_root, "add", "--", relative_path)
49
+
50
+
51
+ @pytest.fixture()
52
+ def temporary_git_repository(tmp_path: Path) -> Path:
53
+ repository_root = tmp_path / "repository_under_test"
54
+ repository_root.mkdir()
55
+ initialize_git_repository(repository_root)
56
+ return repository_root
57
+
58
+
59
+ def test_paths_from_git_staged_returns_staged_files(
60
+ temporary_git_repository: Path,
61
+ ) -> None:
62
+ write_file(temporary_git_repository / "committed_file.py", "one = 1\n")
63
+ commit_all_files(temporary_git_repository, "initial")
64
+ write_file(temporary_git_repository / "newly_staged_file.py", "two = 2\n")
65
+ write_file(temporary_git_repository / "unstaged_file.py", "three = 3\n")
66
+ stage_file(temporary_git_repository, "newly_staged_file.py")
67
+
68
+ staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
69
+
70
+ staged_names = {path.name for path in staged_paths}
71
+ assert "newly_staged_file.py" in staged_names
72
+ assert "unstaged_file.py" not in staged_names
73
+ assert "committed_file.py" not in staged_names
74
+
75
+
76
+ def test_added_lines_for_staged_file_reports_new_lines(
77
+ temporary_git_repository: Path,
78
+ ) -> None:
79
+ write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
80
+ commit_all_files(temporary_git_repository, "baseline")
81
+ write_file(
82
+ temporary_git_repository / "target.py",
83
+ "first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
84
+ )
85
+ stage_file(temporary_git_repository, "target.py")
86
+
87
+ added_line_numbers = gate_module.added_lines_for_staged_file(
88
+ temporary_git_repository,
89
+ "target.py",
90
+ )
91
+
92
+ assert 3 in added_line_numbers
93
+ assert 4 in added_line_numbers
94
+ assert 1 not in added_line_numbers
95
+ assert 2 not in added_line_numbers
96
+
97
+
98
+ def test_added_lines_for_staged_file_treats_new_file_as_fully_added(
99
+ temporary_git_repository: Path,
100
+ ) -> None:
101
+ write_file(temporary_git_repository / "existing.py", "ignored = 0\n")
102
+ commit_all_files(temporary_git_repository, "baseline")
103
+ write_file(
104
+ temporary_git_repository / "brand_new.py",
105
+ "alpha = 1\nbeta = 2\ngamma = 3\n",
106
+ )
107
+ stage_file(temporary_git_repository, "brand_new.py")
108
+
109
+ added_line_numbers = gate_module.added_lines_for_staged_file(
110
+ temporary_git_repository,
111
+ "brand_new.py",
112
+ )
113
+
114
+ assert added_line_numbers == {1, 2, 3}
115
+
116
+
117
+ def test_paths_from_git_staged_uses_null_delimiter(
118
+ temporary_git_repository: Path,
119
+ ) -> None:
120
+ write_file(temporary_git_repository / "first.py", "a = 1\n")
121
+ write_file(temporary_git_repository / "second.py", "b = 2\n")
122
+ commit_all_files(temporary_git_repository, "baseline")
123
+ write_file(temporary_git_repository / "first.py", "a = 10\n")
124
+ write_file(temporary_git_repository / "second.py", "b = 20\n")
125
+ stage_file(temporary_git_repository, "first.py")
126
+ stage_file(temporary_git_repository, "second.py")
127
+
128
+ staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
129
+
130
+ staged_names = {path.name for path in staged_paths}
131
+ assert staged_names == {"first.py", "second.py"}
132
+
133
+
134
+ def test_paths_from_git_staged_warns_and_skips_non_utf8_filename(
135
+ tmp_path: Path,
136
+ capsys: pytest.CaptureFixture[str],
137
+ ) -> None:
138
+ non_utf8_raw = b"valid.py\x00\xff\xfe_bad.py\x00"
139
+ mock_completed = unittest.mock.MagicMock()
140
+ mock_completed.returncode = 0
141
+ mock_completed.stdout = non_utf8_raw
142
+
143
+ with unittest.mock.patch("subprocess.run", return_value=mock_completed):
144
+ result_paths = gate_module.paths_from_git_staged(tmp_path)
145
+
146
+ captured = capsys.readouterr()
147
+ assert "non-UTF-8" in captured.err
148
+ assert len(result_paths) == 1
149
+ assert result_paths[0].name == "valid.py"
150
+
151
+
152
+ def test_staged_added_lines_by_file_maps_every_staged_code_file(
153
+ temporary_git_repository: Path,
154
+ ) -> None:
155
+ write_file(temporary_git_repository / "already_committed.py", "zero = 0\n")
156
+ commit_all_files(temporary_git_repository, "initial")
157
+ write_file(
158
+ temporary_git_repository / "already_committed.py",
159
+ "zero = 0\nappended = 1\n",
160
+ )
161
+ write_file(temporary_git_repository / "added_file.py", "only = 1\n")
162
+ stage_file(temporary_git_repository, "already_committed.py")
163
+ stage_file(temporary_git_repository, "added_file.py")
164
+
165
+ staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
166
+ added_lines_map = gate_module.added_lines_by_file_staged(
167
+ temporary_git_repository,
168
+ staged_paths,
169
+ )
170
+
171
+ resolved_repository_root = temporary_git_repository.resolve()
172
+ assert added_lines_map[resolved_repository_root / "already_committed.py"] == {2}
173
+ assert added_lines_map[resolved_repository_root / "added_file.py"] == {1}
174
+
175
+
176
+ def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
177
+ temporary_git_repository: Path,
178
+ monkeypatch: pytest.MonkeyPatch,
179
+ ) -> None:
180
+ write_file(temporary_git_repository / "module.py", "first_value = 1\n")
181
+ commit_all_files(temporary_git_repository, "initial")
182
+ staged_content_with_banned_identifier = (
183
+ "first_value = 1\n"
184
+ "def compute_total(operand):\n"
185
+ " result = operand + 1\n"
186
+ " return result\n"
187
+ )
188
+ write_file(
189
+ temporary_git_repository / "module.py",
190
+ staged_content_with_banned_identifier,
191
+ )
192
+ stage_file(temporary_git_repository, "module.py")
193
+
194
+ monkeypatch.chdir(temporary_git_repository)
195
+ exit_code = gate_module.main(["--staged"])
196
+
197
+ assert exit_code == 1
198
+
199
+
200
+ def test_main_staged_mode_passes_when_no_staged_violations(
201
+ temporary_git_repository: Path,
202
+ monkeypatch: pytest.MonkeyPatch,
203
+ ) -> None:
204
+ write_file(temporary_git_repository / "module.py", "first_value = 1\n")
205
+ commit_all_files(temporary_git_repository, "initial")
206
+ write_file(
207
+ temporary_git_repository / "module.py", "first_value = 1\nsecond_value = 2\n"
208
+ )
209
+ stage_file(temporary_git_repository, "module.py")
210
+
211
+ monkeypatch.chdir(temporary_git_repository)
212
+ exit_code = gate_module.main(["--staged"])
213
+
214
+ assert exit_code == 0
215
+
216
+
217
+ def test_main_staged_mode_exits_zero_when_nothing_staged(
218
+ temporary_git_repository: Path,
219
+ monkeypatch: pytest.MonkeyPatch,
220
+ ) -> None:
221
+ write_file(temporary_git_repository / "module.py", "first_value = 1\n")
222
+ commit_all_files(temporary_git_repository, "initial")
223
+
224
+ monkeypatch.chdir(temporary_git_repository)
225
+ exit_code = gate_module.main(["--staged"])
226
+
227
+ assert exit_code == 0
228
+
229
+
230
+ def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
231
+ temporary_git_repository: Path,
232
+ ) -> None:
233
+ write_file(
234
+ temporary_git_repository / "existing.py",
235
+ "alpha = 1\nbeta = 2\ngamma = 3\n",
236
+ )
237
+ commit_all_files(temporary_git_repository, "baseline")
238
+ write_file(temporary_git_repository / "existing.py", "alpha = 1\nbeta = 2\n")
239
+ stage_file(temporary_git_repository, "existing.py")
240
+
241
+ added_line_numbers = gate_module.added_lines_for_staged_file(
242
+ temporary_git_repository,
243
+ "existing.py",
244
+ )
245
+
246
+ assert added_line_numbers == set()
247
+
248
+
249
+ def test_is_file_absent_in_index_head_does_not_exist_in_module() -> None:
250
+ assert not hasattr(gate_module, "is_file_absent_in_index_head")
251
+
252
+
253
+ def test_staged_file_line_count_raises_on_git_show_failure(
254
+ tmp_path: Path,
255
+ capsys: pytest.CaptureFixture[str],
256
+ ) -> None:
257
+ """git show failure must surface as SystemExit + stderr, never silent 0."""
258
+ failing_completed = unittest.mock.MagicMock()
259
+ failing_completed.returncode = 128
260
+ failing_completed.stdout = ""
261
+ failing_completed.stderr = "fatal: bad object :missing\n"
262
+ with unittest.mock.patch("subprocess.run", return_value=failing_completed):
263
+ with pytest.raises(SystemExit):
264
+ gate_module.staged_file_line_count(tmp_path, "missing.py")
265
+ captured = capsys.readouterr()
266
+ assert "git show" in captured.err
267
+ assert "fatal: bad object" in captured.err
268
+
269
+
270
+ def test_is_staged_file_newly_added_raises_on_git_failure(
271
+ tmp_path: Path,
272
+ capsys: pytest.CaptureFixture[str],
273
+ ) -> None:
274
+ """git diff --name-status failure must surface as SystemExit + stderr."""
275
+ failing_completed = unittest.mock.MagicMock()
276
+ failing_completed.returncode = 128
277
+ failing_completed.stdout = ""
278
+ failing_completed.stderr = "fatal: not a git repository\n"
279
+ with unittest.mock.patch("subprocess.run", return_value=failing_completed):
280
+ with pytest.raises(SystemExit):
281
+ gate_module.is_staged_file_newly_added(tmp_path, "anything.py")
282
+ captured = capsys.readouterr()
283
+ assert "git diff --cached --name-status" in captured.err
284
+
285
+
286
+ def test_whole_file_line_set_raises_system_exit_on_oserror(
287
+ tmp_path: Path,
288
+ capsys: pytest.CaptureFixture[str],
289
+ ) -> None:
290
+ """OSError reading a file must propagate as SystemExit, not silently return ``set()``.
291
+
292
+ Regression for loop1-7: returning an empty set on OSError caused the gate
293
+ to route every violation to the advisory bucket and exit 0 — silently
294
+ downgrading blocking violations to non-blocking on a read failure.
295
+ """
296
+ unreadable_path = tmp_path / "broken.py"
297
+ with unittest.mock.patch.object(
298
+ Path, "read_text", side_effect=PermissionError("denied")
299
+ ):
300
+ with pytest.raises(SystemExit):
301
+ gate_module.whole_file_line_set(unreadable_path)
302
+ captured = capsys.readouterr()
303
+ assert str(unreadable_path) in captured.err
304
+ assert "denied" in captured.err or "PermissionError" in captured.err
305
+
306
+
307
+ def test_check_database_column_string_magic_signals_cap_exit(
308
+ capsys: pytest.CaptureFixture[str],
309
+ ) -> None:
310
+ """When the issue cap is hit, a 'cap reached' note must reach stderr."""
311
+ source_with_many_column_tuples = "\n".join(
312
+ [
313
+ "def write_rows():",
314
+ " rows = [",
315
+ *[
316
+ f" ('column_name_{each_index}', {each_index}),"
317
+ for each_index in range(10)
318
+ ],
319
+ " ]",
320
+ " return rows",
321
+ ]
322
+ )
323
+ issues = gate_module.check_database_column_string_magic(
324
+ source_with_many_column_tuples,
325
+ "production/file.py",
326
+ )
327
+ assert len(issues) == 3
328
+ captured = capsys.readouterr()
329
+ assert "cap reached" in captured.err.lower()
330
+
331
+
332
+ def test_check_wrapper_plumb_through_signals_cap_exit(
333
+ capsys: pytest.CaptureFixture[str],
334
+ ) -> None:
335
+ """check_wrapper_plumb_through must signal when MAXIMUM_ISSUES_TO_REPORT trims."""
336
+ delegate_definition = (
337
+ "def delegate(*, optional_one=1, optional_two=2, optional_three=3,"
338
+ " optional_four=4): return 0\n"
339
+ )
340
+ wrappers_block = "\n".join(
341
+ f"def wrapper_{each_index}():\n return self.delegate()"
342
+ for each_index in range(5)
343
+ )
344
+ source_with_many_wrappers = delegate_definition + wrappers_block + "\n"
345
+ issues = gate_module.check_wrapper_plumb_through(
346
+ source_with_many_wrappers,
347
+ "production/wrappers.py",
348
+ )
349
+ assert len(issues) == 3
350
+ captured = capsys.readouterr()
351
+ assert "cap reached" in captured.err.lower()
352
+
353
+
354
+ def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
355
+ tmp_path: Path,
356
+ capsys: pytest.CaptureFixture[str],
357
+ ) -> None:
358
+ """Skipping an unreadable file during run_gate must cause a non-zero exit."""
359
+ target_file = tmp_path / "sample.py"
360
+ target_file.write_text("clean = 1\n", encoding="utf-8")
361
+
362
+ def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
363
+ return []
364
+
365
+ with unittest.mock.patch.object(
366
+ Path, "read_text", side_effect=PermissionError("denied")
367
+ ):
368
+ exit_code = gate_module.run_gate(
369
+ fake_validate,
370
+ [target_file],
371
+ tmp_path,
372
+ all_added_lines_map=None,
373
+ )
374
+ captured = capsys.readouterr()
375
+ assert exit_code != 0, (
376
+ "Files skipped due to read errors must produce a non-zero gate exit"
377
+ )
378
+ assert "skip unreadable" in captured.err
379
+
380
+
381
+ def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
382
+ temporary_git_repository: Path,
383
+ monkeypatch: pytest.MonkeyPatch,
384
+ ) -> None:
385
+ write_file(
386
+ temporary_git_repository / "sample.py",
387
+ "alpha = 1\nbeta = 2\n",
388
+ )
389
+ commit_all_files(temporary_git_repository, "baseline")
390
+ write_file(temporary_git_repository / "sample.py", "alpha = 1\nbeta = 2\ngamma = 3\n")
391
+ stage_file(temporary_git_repository, "sample.py")
392
+
393
+ monkeypatch.setattr(gate_module, "parse_added_line_numbers", lambda _text: set())
394
+
395
+ added_line_numbers = gate_module.added_lines_for_staged_file(
396
+ temporary_git_repository,
397
+ "sample.py",
398
+ )
399
+
400
+ assert added_line_numbers == set()