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,976 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import importlib.util
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+
12
+ ValidateContentCallable = Callable[..., list[str]]
13
+
14
+ _previously_cached_config = {}
15
+ for each_cached_module_name in [
16
+ each_module_key
17
+ for each_module_key in list(sys.modules)
18
+ if each_module_key == "config" or each_module_key.startswith("config.")
19
+ ]:
20
+ _previously_cached_config[each_cached_module_name] = sys.modules.pop(
21
+ each_cached_module_name
22
+ )
23
+
24
+ from config.bugteam_code_rules_gate_constants import (
25
+ ALL_CODE_FILE_EXTENSIONS,
26
+ ALL_COLUMN_MAGIC_FALSE_VALUES,
27
+ ALL_GIT_DIFF_CACHED_ARGS,
28
+ ALL_JS_FILE_EXTENSIONS,
29
+ BUGTEAM_CODE_RULES_GATE_PREFIX,
30
+ EXIT_CODE_ENFORCER_MISSING,
31
+ HUNK_HEADER_RAW_PATTERN,
32
+ MAXIMUM_COLUMN_TUPLE_ELEMENT_COUNT,
33
+ MAXIMUM_ISSUES_TO_REPORT,
34
+ VIOLATION_LINE_RAW_PATTERN,
35
+ )
36
+
37
+ sys.modules.update(_previously_cached_config)
38
+
39
+
40
+ def hunk_header_pattern() -> re.Pattern[str]:
41
+ return re.compile(HUNK_HEADER_RAW_PATTERN)
42
+
43
+
44
+ def violation_line_pattern() -> re.Pattern[str]:
45
+ return re.compile(VIOLATION_LINE_RAW_PATTERN)
46
+
47
+
48
+ def resolve_claude_dev_env_root() -> Path:
49
+ environment_value = (Path(__file__).resolve().parents[3]).resolve()
50
+ return environment_value
51
+
52
+
53
+ def load_validate_content() -> ValidateContentCallable:
54
+ """Load and return the validate_content function from the CODE_RULES enforcer.
55
+
56
+ Dynamically imports the code_rules_enforcer module by resolving its path
57
+ relative to the current file's location. Temporarily removes the gate
58
+ script's ``config`` from ``sys.modules`` to avoid a namespace clash with
59
+ the enforcer's ``hooks/config/`` package.
60
+
61
+ Not thread-safe: mutates the process-global ``sys.modules`` mapping. Call
62
+ only from single-threaded contexts (the CLI entry point at ``main`` is
63
+ safe; concurrent invocations from multiple threads must wrap calls in an
64
+ external lock).
65
+
66
+ Returns:
67
+ The validate_content callable from the loaded enforcer module.
68
+
69
+ Raises:
70
+ SystemExit: When the enforcer file is missing or cannot be loaded.
71
+ """
72
+ package_root = resolve_claude_dev_env_root()
73
+ enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
74
+ if not enforcer_path.is_file():
75
+ print(
76
+ f"missing enforcer at {enforcer_path}",
77
+ file=sys.stderr,
78
+ )
79
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
80
+ previously_cached_config = {}
81
+ for each_cached_module_name in [
82
+ each_module_key
83
+ for each_module_key in list(sys.modules)
84
+ if each_module_key == "config" or each_module_key.startswith("config.")
85
+ ]:
86
+ previously_cached_config[each_cached_module_name] = sys.modules.pop(
87
+ each_cached_module_name
88
+ )
89
+ hooks_config_init = package_root / "hooks" / "config" / "__init__.py"
90
+ if hooks_config_init.is_file():
91
+ hooks_config_spec = importlib.util.spec_from_file_location(
92
+ "config",
93
+ hooks_config_init,
94
+ )
95
+ if hooks_config_spec is not None and hooks_config_spec.loader is not None:
96
+ hooks_config_module = importlib.util.module_from_spec(hooks_config_spec)
97
+ sys.modules["config"] = hooks_config_module
98
+ hooks_config_spec.loader.exec_module(hooks_config_module)
99
+ try:
100
+ specification = importlib.util.spec_from_file_location(
101
+ "code_rules_enforcer",
102
+ enforcer_path,
103
+ )
104
+ if specification is None or specification.loader is None:
105
+ print("could not load code_rules_enforcer.", file=sys.stderr)
106
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
107
+ module = importlib.util.module_from_spec(specification)
108
+ specification.loader.exec_module(module)
109
+ return module.validate_content
110
+ finally:
111
+ sys.modules.update(previously_cached_config)
112
+
113
+
114
+ def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
115
+ """Resolve the merge-base commit between HEAD and a base reference.
116
+
117
+ Args:
118
+ repository_root: The root directory of the git repository.
119
+ base_reference: The git reference to compare against (e.g., origin/main).
120
+
121
+ Returns:
122
+ The merge-base commit hash as a string.
123
+
124
+ Raises:
125
+ SystemExit: When git merge-base fails.
126
+ """
127
+ merge_result = subprocess.run(
128
+ ["git", "merge-base", "HEAD", base_reference],
129
+ cwd=str(repository_root),
130
+ capture_output=True,
131
+ text=True,
132
+ encoding="utf-8",
133
+ errors="replace",
134
+ check=False,
135
+ )
136
+ if merge_result.returncode != 0:
137
+ print(
138
+ f"git merge-base HEAD {base_reference} failed:\n"
139
+ f"{merge_result.stderr}",
140
+ file=sys.stderr,
141
+ )
142
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
143
+ return merge_result.stdout.strip()
144
+
145
+
146
+ def filter_paths_under_prefixes(
147
+ all_file_paths: list[Path],
148
+ repository_root: Path,
149
+ all_prefixes: list[str],
150
+ ) -> list[Path]:
151
+ """Filter a list of file paths to keep only those under the given prefixes.
152
+
153
+ Args:
154
+ all_file_paths: File paths to filter.
155
+ repository_root: The repository root for resolving relative paths.
156
+ all_prefixes: Prefixes to match against (POSIX-style, relative to root).
157
+
158
+ Returns:
159
+ Filtered list of file paths whose repo-relative path starts with a prefix.
160
+ """
161
+ if not all_prefixes:
162
+ return all_file_paths
163
+ normalized_prefixes = [
164
+ each_prefix.strip().replace("\\", "/").rstrip("/")
165
+ for each_prefix in all_prefixes
166
+ if each_prefix.strip()
167
+ ]
168
+ if not normalized_prefixes:
169
+ return all_file_paths
170
+ resolved_root = repository_root.resolve()
171
+ filtered: list[Path] = []
172
+ for each_path in all_file_paths:
173
+ try:
174
+ relative_posix = each_path.resolve().relative_to(resolved_root).as_posix()
175
+ except ValueError:
176
+ continue
177
+ if any(
178
+ relative_posix == each_prefix or relative_posix.startswith(each_prefix + "/")
179
+ for each_prefix in normalized_prefixes
180
+ ):
181
+ filtered.append(each_path)
182
+ return filtered
183
+
184
+
185
+ def paths_from_git_staged(repository_root: Path) -> list[Path]:
186
+ """Retrieve file paths that are staged for commit.
187
+
188
+ Uses ``git diff --cached --name-only -z`` to get the list of staged files.
189
+
190
+ Args:
191
+ repository_root: The repository root for running git commands.
192
+
193
+ Returns:
194
+ List of absolute Path objects for each staged file.
195
+
196
+ Raises:
197
+ SystemExit: When the git command fails.
198
+ """
199
+ name_result = subprocess.run(
200
+ list(ALL_GIT_DIFF_CACHED_ARGS),
201
+ cwd=str(repository_root),
202
+ capture_output=True,
203
+ check=False,
204
+ )
205
+ if name_result.returncode != 0:
206
+ stderr_text = name_result.stderr.decode("utf-8", errors="replace")
207
+ print(
208
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --cached --name-only -z failed:\n{stderr_text}",
209
+ file=sys.stderr,
210
+ )
211
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
212
+ raw_paths = name_result.stdout.split(b"\x00")
213
+ resolved_paths = []
214
+ for each_raw_path in raw_paths:
215
+ if not each_raw_path:
216
+ continue
217
+ try:
218
+ relative_path = each_raw_path.decode("utf-8")
219
+ except UnicodeDecodeError:
220
+ print(
221
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
222
+ file=sys.stderr,
223
+ )
224
+ continue
225
+ resolved_paths.append(repository_root / relative_path)
226
+ return resolved_paths
227
+
228
+
229
+ def staged_file_line_count(
230
+ repository_root: Path,
231
+ relative_path_posix: str,
232
+ ) -> int:
233
+ """Count lines in a staged file.
234
+
235
+ Args:
236
+ repository_root: The repository root.
237
+ relative_path_posix: POSIX-style relative path to the staged file.
238
+
239
+ Returns:
240
+ Number of lines in the staged file (zero only when the file is genuinely empty).
241
+
242
+ Raises:
243
+ SystemExit: When ``git show`` fails. Returning zero on git errors
244
+ would be indistinguishable from an empty file and would silently
245
+ cause the gate to skip validating a newly added file.
246
+ """
247
+ show_result = subprocess.run(
248
+ ["git", "show", f":{relative_path_posix}"],
249
+ cwd=str(repository_root),
250
+ capture_output=True,
251
+ text=True,
252
+ encoding="utf-8",
253
+ errors="replace",
254
+ check=False,
255
+ )
256
+ if show_result.returncode != 0:
257
+ print(
258
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git show :{relative_path_posix} failed:\n"
259
+ f"{show_result.stderr}",
260
+ file=sys.stderr,
261
+ )
262
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
263
+ staged_content = show_result.stdout
264
+ if not staged_content:
265
+ return 0
266
+ return len(staged_content.splitlines())
267
+
268
+
269
+ def is_staged_file_newly_added(
270
+ repository_root: Path,
271
+ relative_path_posix: str,
272
+ ) -> bool:
273
+ """Check whether a staged file is newly added (not previously tracked).
274
+
275
+ Args:
276
+ repository_root: The repository root.
277
+ relative_path_posix: POSIX-style relative path to the staged file.
278
+
279
+ Returns:
280
+ True when the file status starts with 'A' (added).
281
+
282
+ Raises:
283
+ SystemExit: When ``git diff --cached --name-status`` fails. Returning
284
+ False on git errors would be indistinguishable from "modified, not
285
+ added" and would cause the gate to silently skip validating a
286
+ newly added file.
287
+ """
288
+ status_result = subprocess.run(
289
+ ["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
290
+ cwd=str(repository_root),
291
+ capture_output=True,
292
+ text=True,
293
+ encoding="utf-8",
294
+ errors="replace",
295
+ check=False,
296
+ )
297
+ if status_result.returncode != 0:
298
+ print(
299
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --cached --name-status failed for "
300
+ f"{relative_path_posix}:\n{status_result.stderr}",
301
+ file=sys.stderr,
302
+ )
303
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
304
+ for each_line in status_result.stdout.splitlines():
305
+ stripped_line = each_line.strip()
306
+ if stripped_line:
307
+ return stripped_line.startswith("A")
308
+ return False
309
+
310
+
311
+ def added_lines_for_staged_file(
312
+ repository_root: Path,
313
+ relative_path_posix: str,
314
+ ) -> set[int]:
315
+ """Determine which lines were added in a staged file.
316
+
317
+ Uses ``git diff --cached --unified=0``. For newly added files, returns
318
+ the full range of line numbers.
319
+
320
+ Args:
321
+ repository_root: The repository root.
322
+ relative_path_posix: POSIX-style relative path to the staged file.
323
+
324
+ Returns:
325
+ Set of added line numbers (1-based).
326
+
327
+ Raises:
328
+ SystemExit: When the git diff command fails.
329
+ """
330
+ diff_result = subprocess.run(
331
+ ["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
332
+ cwd=str(repository_root),
333
+ capture_output=True,
334
+ text=True,
335
+ encoding="utf-8",
336
+ errors="replace",
337
+ check=False,
338
+ )
339
+ if diff_result.returncode != 0:
340
+ print(
341
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --cached --unified=0 failed for {relative_path_posix}:\n"
342
+ f"{diff_result.stderr}",
343
+ file=sys.stderr,
344
+ )
345
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
346
+ if diff_result.stdout.strip():
347
+ return parse_added_line_numbers(diff_result.stdout)
348
+ if is_staged_file_newly_added(repository_root, relative_path_posix):
349
+ total_lines = staged_file_line_count(repository_root, relative_path_posix)
350
+ if total_lines > 0:
351
+ return set(range(1, total_lines + 1))
352
+ return set()
353
+
354
+
355
+ def added_lines_by_file_staged(
356
+ repository_root: Path,
357
+ all_file_paths: list[Path],
358
+ ) -> dict[Path, set[int]]:
359
+ """Map each staged file path to the set of added line numbers.
360
+
361
+ Args:
362
+ repository_root: The repository root.
363
+ all_file_paths: Staged file paths to check.
364
+
365
+ Returns:
366
+ Dictionary mapping resolved file paths to their added line numbers.
367
+ """
368
+ resolved_root = repository_root.resolve()
369
+ added_by_path: dict[Path, set[int]] = {}
370
+ for each_path in all_file_paths:
371
+ try:
372
+ resolved = each_path.resolve()
373
+ except OSError:
374
+ continue
375
+ try:
376
+ relative = resolved.relative_to(resolved_root)
377
+ except ValueError:
378
+ continue
379
+ relative_posix = str(relative).replace("\\", "/")
380
+ added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
381
+ added_by_path[resolved] = added_numbers
382
+ return added_by_path
383
+
384
+
385
+ def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
386
+ """Retrieve file paths changed between merge-base and HEAD.
387
+
388
+ Args:
389
+ repository_root: The repository root.
390
+ base_reference: The git reference for the merge-base comparison.
391
+
392
+ Returns:
393
+ List of absolute Path objects for changed files.
394
+
395
+ Raises:
396
+ SystemExit: When the git diff command fails.
397
+ """
398
+ merge_base = resolve_merge_base(repository_root, base_reference)
399
+ name_result = subprocess.run(
400
+ ["git", "diff", "--name-only", f"{merge_base}..HEAD"],
401
+ cwd=str(repository_root),
402
+ capture_output=True,
403
+ text=True,
404
+ encoding="utf-8",
405
+ errors="replace",
406
+ check=False,
407
+ )
408
+ if name_result.returncode != 0:
409
+ print(
410
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --name-only failed:\n{name_result.stderr}",
411
+ file=sys.stderr,
412
+ )
413
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
414
+ relative_paths = [line.strip() for line in name_result.stdout.splitlines() if line.strip()]
415
+ return [repository_root / each_relative_path for each_relative_path in relative_paths]
416
+
417
+
418
+ def is_code_path(file_path: Path) -> bool:
419
+ """Check whether a file path has a recognized code file extension.
420
+
421
+ Args:
422
+ file_path: The file path to check.
423
+
424
+ Returns:
425
+ True when the file extension is in the set of code extensions.
426
+ """
427
+ suffix = file_path.suffix.lower()
428
+ return suffix in ALL_CODE_FILE_EXTENSIONS
429
+
430
+
431
+ def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
432
+ """Flag string literals that look like database/HTTP column or key names inside function bodies.
433
+
434
+ Triggers when a snake_case string literal appears as the first element of a
435
+ two-element tuple inside a function body (the characteristic column-name/value
436
+ pair pattern). Files under ``config/`` and test files are exempt.
437
+
438
+ Args:
439
+ content: The source code content to inspect.
440
+ file_path: The file path for exemption checks.
441
+
442
+ Returns:
443
+ List of violation messages, or an empty list when no violations are found.
444
+ """
445
+ if "/config/" in file_path.replace("\\", "/") or "\\config\\" in file_path:
446
+ return []
447
+ if "/tests/" in file_path.replace("\\", "/") or file_path.endswith(("_test.py", ".spec.py")):
448
+ return []
449
+ try:
450
+ tree = ast.parse(content)
451
+ except SyntaxError:
452
+ return []
453
+ issues: list[str] = []
454
+ column_key_pattern = re.compile(r"^[a-z][a-z0-9_]{2,}$")
455
+ for each_node in ast.walk(tree):
456
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
457
+ continue
458
+ for each_child in ast.walk(each_node):
459
+ if not isinstance(each_child, ast.Tuple):
460
+ continue
461
+ if len(each_child.elts) != MAXIMUM_COLUMN_TUPLE_ELEMENT_COUNT:
462
+ continue
463
+ first_element = each_child.elts[0]
464
+ if not isinstance(first_element, ast.Constant):
465
+ continue
466
+ if not isinstance(first_element.value, str):
467
+ continue
468
+ literal_text = first_element.value
469
+ if not column_key_pattern.match(literal_text):
470
+ continue
471
+ if literal_text in ALL_COLUMN_MAGIC_FALSE_VALUES:
472
+ continue
473
+ issues.append(
474
+ f"Line {first_element.lineno}: Column-name string magic {literal_text!r} - extract to config"
475
+ )
476
+ if len(issues) >= MAXIMUM_ISSUES_TO_REPORT:
477
+ print(
478
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}check_database_column_string_magic "
479
+ f"cap reached at {MAXIMUM_ISSUES_TO_REPORT} issues for {file_path}; "
480
+ "additional matches were dropped.",
481
+ file=sys.stderr,
482
+ )
483
+ return issues
484
+ return issues
485
+
486
+
487
+ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
488
+ """Flag public wrappers that drop optional kwargs of a same-file delegate.
489
+
490
+ Walks the AST. For every public function (name does not start with '_'),
491
+ if its body contains exactly one direct call to another same-file
492
+ function and that delegate's signature accepts optional kwargs that the
493
+ wrapper does not also accept, emit a finding with both line numbers.
494
+
495
+ Args:
496
+ content: The source code content to inspect.
497
+ file_path: The file path for JS/TS extension exemption.
498
+
499
+ Returns:
500
+ List of violation messages, or an empty list when no violations are found.
501
+ """
502
+ if file_path.endswith(ALL_JS_FILE_EXTENSIONS):
503
+ return []
504
+ try:
505
+ tree = ast.parse(content)
506
+ except SyntaxError:
507
+ return []
508
+ function_signatures: dict[str, set[str]] = {}
509
+ for each_node in ast.walk(tree):
510
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
511
+ optional_kwargs: set[str] = set()
512
+ for each_kwonly, each_default in zip(each_node.args.kwonlyargs, each_node.args.kw_defaults):
513
+ if each_default is not None:
514
+ optional_kwargs.add(each_kwonly.arg)
515
+ function_signatures[each_node.name] = optional_kwargs
516
+ issues: list[str] = []
517
+ for each_node in ast.walk(tree):
518
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
519
+ continue
520
+ if each_node.name.startswith("_"):
521
+ continue
522
+ wrapper_kwargs = function_signatures.get(each_node.name, set())
523
+ for each_call in ast.walk(each_node):
524
+ if not isinstance(each_call, ast.Call):
525
+ continue
526
+ if not isinstance(each_call.func, ast.Attribute):
527
+ continue
528
+ delegate_name = each_call.func.attr
529
+ delegate_kwargs = function_signatures.get(delegate_name)
530
+ if delegate_kwargs is None:
531
+ continue
532
+ missing = delegate_kwargs - wrapper_kwargs
533
+ if missing:
534
+ issues.append(
535
+ f"Line {each_node.lineno}: Wrapper {each_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
536
+ )
537
+ if len(issues) >= MAXIMUM_ISSUES_TO_REPORT:
538
+ print(
539
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}check_wrapper_plumb_through "
540
+ f"cap reached at {MAXIMUM_ISSUES_TO_REPORT} issues for {file_path}; "
541
+ "additional matches were dropped.",
542
+ file=sys.stderr,
543
+ )
544
+ return issues
545
+ return issues
546
+
547
+
548
+ def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
549
+ """Parse unified diff text and return the set of added line numbers.
550
+
551
+ Args:
552
+ unified_diff_text: The unified diff output to parse.
553
+
554
+ Returns:
555
+ Set of line numbers (1-based) that were added in the diff.
556
+ """
557
+ header_regex = hunk_header_pattern()
558
+ added_line_numbers: set[int] = set()
559
+ for each_line in unified_diff_text.splitlines():
560
+ header_match = header_regex.match(each_line)
561
+ if header_match is None:
562
+ continue
563
+ new_start_text, new_count_text = header_match.groups()
564
+ new_start = int(new_start_text)
565
+ new_count = 1 if new_count_text is None else int(new_count_text)
566
+ if new_count <= 0:
567
+ continue
568
+ for each_number in range(new_start, new_start + new_count):
569
+ added_line_numbers.add(each_number)
570
+ return added_line_numbers
571
+
572
+
573
+ def is_file_new_at_base(
574
+ repository_root: Path,
575
+ merge_base: str,
576
+ relative_path_posix: str,
577
+ ) -> bool:
578
+ """Check whether a file did not exist at the merge-base commit.
579
+
580
+ Args:
581
+ repository_root: The repository root.
582
+ merge_base: The merge-base commit reference.
583
+ relative_path_posix: POSIX-style relative path to check.
584
+
585
+ Returns:
586
+ True when the file does not exist in the base commit.
587
+ """
588
+ cat_result = subprocess.run(
589
+ ["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
590
+ cwd=str(repository_root),
591
+ capture_output=True,
592
+ text=True,
593
+ encoding="utf-8",
594
+ errors="replace",
595
+ check=False,
596
+ )
597
+ return cat_result.returncode != 0
598
+
599
+
600
+ def added_lines_for_file(
601
+ repository_root: Path,
602
+ merge_base: str,
603
+ relative_path_posix: str,
604
+ ) -> set[int]:
605
+ """Determine which lines were added in a file between merge-base and HEAD.
606
+
607
+ Args:
608
+ repository_root: The repository root.
609
+ merge_base: The merge-base commit reference.
610
+ relative_path_posix: POSIX-style relative path to the file.
611
+
612
+ Returns:
613
+ Set of added line numbers (1-based).
614
+
615
+ Raises:
616
+ SystemExit: When the git diff command fails.
617
+ """
618
+ diff_result = subprocess.run(
619
+ ["git", "diff", "--unified=0", f"{merge_base}..HEAD", "--", relative_path_posix],
620
+ cwd=str(repository_root),
621
+ capture_output=True,
622
+ text=True,
623
+ encoding="utf-8",
624
+ errors="replace",
625
+ check=False,
626
+ )
627
+ if diff_result.returncode != 0:
628
+ print(
629
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --unified=0 failed for {relative_path_posix}:\n"
630
+ f"{diff_result.stderr}",
631
+ file=sys.stderr,
632
+ )
633
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
634
+ if not diff_result.stdout.strip():
635
+ return set()
636
+ return parse_added_line_numbers(diff_result.stdout)
637
+
638
+
639
+ def whole_file_line_set(file_path: Path) -> set[int]:
640
+ """Return a set of all line numbers in a file.
641
+
642
+ Args:
643
+ file_path: Path to the file.
644
+
645
+ Returns:
646
+ Set of line numbers (1-based), or an empty set when the file is empty.
647
+
648
+ Raises:
649
+ SystemExit: When the file cannot be read; an empty set must not be
650
+ returned on read failure because the caller treats it as
651
+ "no lines changed" and silently downgrades blocking violations.
652
+ """
653
+ try:
654
+ total_lines = len(file_path.read_text().splitlines())
655
+ except OSError as read_error:
656
+ print(
657
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}whole_file_line_set could not read "
658
+ f"{file_path}: {type(read_error).__name__}: {read_error}",
659
+ file=sys.stderr,
660
+ )
661
+ raise SystemExit(EXIT_CODE_ENFORCER_MISSING) from read_error
662
+ if total_lines <= 0:
663
+ return set()
664
+ return set(range(1, total_lines + 1))
665
+
666
+
667
+ def added_lines_by_file(
668
+ repository_root: Path,
669
+ base_reference: str,
670
+ all_file_paths: list[Path],
671
+ ) -> dict[Path, set[int]]:
672
+ """Map each changed file path to the set of added line numbers vs merge-base.
673
+
674
+ Args:
675
+ repository_root: The repository root.
676
+ base_reference: The base reference for merge-base comparison.
677
+ all_file_paths: File paths to check.
678
+
679
+ Returns:
680
+ Dictionary mapping resolved file paths to their added line numbers.
681
+ """
682
+ merge_base = resolve_merge_base(repository_root, base_reference)
683
+ resolved_root = repository_root.resolve()
684
+ added_by_path: dict[Path, set[int]] = {}
685
+ for each_path in all_file_paths:
686
+ try:
687
+ resolved = each_path.resolve()
688
+ except OSError:
689
+ continue
690
+ try:
691
+ relative = resolved.relative_to(resolved_root)
692
+ except ValueError:
693
+ continue
694
+ relative_posix = str(relative).replace("\\", "/")
695
+ added_numbers = added_lines_for_file(resolved_root, merge_base, relative_posix)
696
+ if not added_numbers and resolved.is_file():
697
+ if is_file_new_at_base(resolved_root, merge_base, relative_posix):
698
+ added_numbers = whole_file_line_set(resolved)
699
+ added_by_path[resolved] = added_numbers
700
+ return added_by_path
701
+
702
+
703
+ def extract_violation_line_number(violation_text: str) -> int | None:
704
+ """Extract the line number from a violation message.
705
+
706
+ Args:
707
+ violation_text: The violation message text.
708
+
709
+ Returns:
710
+ The extracted line number, or None when no line number is present.
711
+ """
712
+ match_result = violation_line_pattern().match(violation_text)
713
+ if match_result is None:
714
+ return None
715
+ return int(match_result.group(1))
716
+
717
+
718
+ def split_violations_by_scope(
719
+ all_issues: list[str],
720
+ all_added_line_numbers: set[int] | None,
721
+ ) -> tuple[list[str], list[str]]:
722
+ """Split violations into blocking and advisory groups by line number.
723
+
724
+ Args:
725
+ all_issues: All violation messages to split.
726
+ all_added_line_numbers: Set of added line numbers, or None for full-file scope.
727
+
728
+ Returns:
729
+ Tuple of (blocking_issues, advisory_issues).
730
+ """
731
+ if all_added_line_numbers is None:
732
+ return list(all_issues), []
733
+ blocking: list[str] = []
734
+ advisory: list[str] = []
735
+ for each_issue in all_issues:
736
+ violation_line = extract_violation_line_number(each_issue)
737
+ if violation_line is None:
738
+ blocking.append(each_issue)
739
+ continue
740
+ if violation_line in all_added_line_numbers:
741
+ blocking.append(each_issue)
742
+ else:
743
+ advisory.append(each_issue)
744
+ return blocking, advisory
745
+
746
+
747
+ def print_violation_section(
748
+ header_message: str,
749
+ violations_by_file: dict[Path, list[str]],
750
+ repository_root: Path,
751
+ ) -> None:
752
+ """Print a section of grouped violation messages grouped by file.
753
+
754
+ Args:
755
+ header_message: The section header to print first.
756
+ violations_by_file: Violations grouped by file path.
757
+ repository_root: Root for computing relative file paths.
758
+ """
759
+ print(header_message, file=sys.stderr)
760
+ resolved_root = repository_root.resolve()
761
+ for each_path in sorted(violations_by_file.keys()):
762
+ relative = each_path.relative_to(resolved_root)
763
+ print(f"{relative}:", file=sys.stderr)
764
+ for each_issue in violations_by_file[each_path]:
765
+ print(f" {each_issue}", file=sys.stderr)
766
+
767
+
768
+ def run_gate(
769
+ validate_content: ValidateContentCallable,
770
+ all_file_paths: list[Path],
771
+ repository_root: Path,
772
+ all_added_lines_map: dict[Path, set[int]] | None,
773
+ ) -> int:
774
+ """Run the CODE_RULES gate on a set of file paths.
775
+
776
+ Applies validate_content, column-string-magic, and wrapper-plumb-through
777
+ checks to each file, then reports violations grouped by file.
778
+
779
+ Args:
780
+ validate_content: The validator function from code_rules_enforcer.
781
+ all_file_paths: File paths to validate.
782
+ repository_root: The repository root for relative path resolution.
783
+ all_added_lines_map: Optional map of resolved path to added line numbers.
784
+ When provided, violations on added lines are blocking; others are advisory.
785
+
786
+ Returns:
787
+ Zero when every targeted file was validated and no blocking
788
+ violations were found. Non-zero when any blocking violations were
789
+ found OR when one or more files could not be read (a skipped file
790
+ means the gate could not vouch for it).
791
+ """
792
+ blocking_by_file: dict[Path, list[str]] = {}
793
+ advisory_by_file: dict[Path, list[str]] = {}
794
+ skipped_unreadable_count = 0
795
+ for each_file_path in sorted(set(all_file_paths)):
796
+ try:
797
+ resolved = each_file_path.resolve()
798
+ except OSError:
799
+ continue
800
+ try:
801
+ resolved.relative_to(repository_root.resolve())
802
+ except ValueError:
803
+ continue
804
+ if not is_code_path(resolved):
805
+ continue
806
+ if not resolved.is_file():
807
+ continue
808
+ try:
809
+ content = resolved.read_text(encoding="utf-8")
810
+ except OSError:
811
+ print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved}", file=sys.stderr)
812
+ skipped_unreadable_count += 1
813
+ continue
814
+ relative = resolved.relative_to(repository_root.resolve())
815
+ issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
816
+ issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
817
+ issues.extend(check_wrapper_plumb_through(content, str(relative).replace("\\", "/")))
818
+ if not issues:
819
+ continue
820
+ added_for_file = None if all_added_lines_map is None else all_added_lines_map.get(resolved)
821
+ blocking, advisory = split_violations_by_scope(issues, added_for_file)
822
+ if blocking:
823
+ blocking_by_file[resolved] = blocking
824
+ if advisory:
825
+ advisory_by_file[resolved] = advisory
826
+ blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
827
+ advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
828
+ if blocking_count:
829
+ if all_added_lines_map is None:
830
+ header = f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) reported."
831
+ else:
832
+ header = (
833
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) "
834
+ "introduced on changed lines:"
835
+ )
836
+ print_violation_section(
837
+ header,
838
+ blocking_by_file,
839
+ repository_root,
840
+ )
841
+ if advisory_count:
842
+ if blocking_count:
843
+ print("", file=sys.stderr)
844
+ print_violation_section(
845
+ (
846
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{advisory_count} pre-existing violation(s) "
847
+ "in touched files (advisory, not blocking):"
848
+ ),
849
+ advisory_by_file,
850
+ repository_root,
851
+ )
852
+ if skipped_unreadable_count:
853
+ print(
854
+ f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{skipped_unreadable_count} file(s) "
855
+ "skipped due to read errors; gate cannot vouch for those files.",
856
+ file=sys.stderr,
857
+ )
858
+ if blocking_count or skipped_unreadable_count:
859
+ return 1
860
+ return 0
861
+
862
+
863
+ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
864
+ """Parse command-line arguments for the bugteam CODE_RULES gate.
865
+
866
+ Args:
867
+ all_argv: Command-line argument list.
868
+
869
+ Returns:
870
+ Parsed namespace with repo_root, base, staged, only_under, and paths.
871
+ """
872
+ parser = argparse.ArgumentParser(
873
+ description=(
874
+ "Run CODE_RULES validators (validate_content) on files in the working tree. "
875
+ "Default file set: git diff --name-only merge-base(base)..HEAD."
876
+ ),
877
+ )
878
+ parser.add_argument(
879
+ "--repo-root",
880
+ type=Path,
881
+ default=None,
882
+ help="Repository root (default: cwd).",
883
+ )
884
+ parser.add_argument(
885
+ "--base",
886
+ default="origin/main",
887
+ help="Merge-base ref for git diff (default: origin/main).",
888
+ )
889
+ parser.add_argument(
890
+ "--staged",
891
+ action="store_true",
892
+ default=False,
893
+ help=(
894
+ "Scope to staged changes only (git diff --cached). "
895
+ "Blocks on violations introduced on staged-added lines; "
896
+ "reports pre-existing violations in touched files as advisory."
897
+ ),
898
+ )
899
+ parser.add_argument(
900
+ "--only-under",
901
+ action="append",
902
+ default=[],
903
+ dest="only_under",
904
+ metavar="PREFIX",
905
+ help=(
906
+ "After resolving the merge-base diff, keep only files whose repo-relative path "
907
+ "uses POSIX slashes and starts with PREFIX or equals PREFIX (repeatable)."
908
+ ),
909
+ )
910
+ parser.add_argument(
911
+ "paths",
912
+ nargs="*",
913
+ type=Path,
914
+ help="Optional explicit files; if set, git diff is not used.",
915
+ )
916
+ return parser.parse_args(all_argv)
917
+
918
+
919
+ def main(all_arguments: list[str]) -> int:
920
+ """Entry point for the bugteam CODE_RULES gate.
921
+
922
+ Parses arguments, loads the validate_content function, determines the
923
+ file scope (staged, diff against base, or explicit paths), and runs
924
+ the gate.
925
+
926
+ Args:
927
+ all_arguments: Command-line arguments to parse.
928
+
929
+ Returns:
930
+ Zero when all checks pass, non-zero on violations or errors.
931
+ """
932
+ arguments = parse_arguments(all_arguments)
933
+ repository_root = (
934
+ arguments.repo_root.resolve()
935
+ if arguments.repo_root is not None
936
+ else Path.cwd().resolve()
937
+ )
938
+ validate_content = load_validate_content()
939
+ if arguments.paths:
940
+ all_parsed_paths = [repository_root / each_path for each_path in arguments.paths]
941
+ return run_gate(validate_content, all_parsed_paths, repository_root, all_added_lines_map=None)
942
+ if arguments.staged:
943
+ staged_file_paths = paths_from_git_staged(repository_root)
944
+ staged_file_paths = filter_paths_under_prefixes(
945
+ staged_file_paths,
946
+ repository_root,
947
+ arguments.only_under,
948
+ )
949
+ if not staged_file_paths:
950
+ return 0
951
+ staged_added_lines = added_lines_by_file_staged(repository_root, staged_file_paths)
952
+ return run_gate(
953
+ validate_content,
954
+ staged_file_paths,
955
+ repository_root,
956
+ all_added_lines_map=staged_added_lines,
957
+ )
958
+ all_diff_paths = paths_from_git_diff(repository_root, arguments.base)
959
+ all_diff_paths = filter_paths_under_prefixes(
960
+ all_diff_paths,
961
+ repository_root,
962
+ arguments.only_under,
963
+ )
964
+ if not all_diff_paths:
965
+ return 0
966
+ scoped_added_lines = added_lines_by_file(repository_root, arguments.base, all_diff_paths)
967
+ return run_gate(
968
+ validate_content,
969
+ all_diff_paths,
970
+ repository_root,
971
+ all_added_lines_map=scoped_added_lines,
972
+ )
973
+
974
+
975
+ if __name__ == "__main__":
976
+ raise SystemExit(main(sys.argv[1:]))