claude-dev-env 1.94.0 → 2.0.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 (280) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +41 -33
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/CLAUDE.md +10 -1
  42. package/bin/ever-shipped-skills.mjs +73 -0
  43. package/bin/expand_home_directory_tokens.mjs +1 -1
  44. package/bin/install.mjs +140 -7
  45. package/bin/install.prune.test.mjs +457 -0
  46. package/docs/CODE_RULES.md +1 -1
  47. package/hooks/advisory/refactor_guard.py +3 -4
  48. package/hooks/blocking/CLAUDE.md +7 -1
  49. package/hooks/blocking/block_main_commit.py +2 -2
  50. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  61. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  62. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  63. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  64. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  65. package/hooks/blocking/code_rules_enforcer.py +4 -0
  66. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  67. package/hooks/blocking/code_rules_optional_params.py +2 -2
  68. package/hooks/blocking/code_rules_shared.py +83 -1
  69. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  70. package/hooks/blocking/code_rules_test_layout.py +9 -3
  71. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  72. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  73. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  74. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  75. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  76. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  77. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  78. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  79. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  80. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  85. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  86. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  87. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  88. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  89. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  90. package/hooks/blocking/pii_payload_scan.py +138 -42
  91. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  92. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  93. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  94. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  95. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  96. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  97. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  98. package/hooks/blocking/plain_language_blocker.py +138 -4
  99. package/hooks/blocking/pr_description_command_parser.py +8 -4
  100. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  101. package/hooks/blocking/sensitive_file_protector.py +114 -48
  102. package/hooks/blocking/tdd_enforcer.py +97 -601
  103. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  104. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  105. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  106. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  107. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  108. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  109. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  110. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  111. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  112. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  113. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  114. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  115. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  116. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  117. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  118. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  119. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  120. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  121. package/hooks/blocking/test_code_rules_shared.py +181 -0
  122. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  123. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  124. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  125. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  126. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  127. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  128. package/hooks/blocking/tests/conftest.py +10 -0
  129. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  130. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  131. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  132. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  133. package/hooks/git-hooks/post_commit.py +3 -4
  134. package/hooks/hooks_constants/CLAUDE.md +4 -2
  135. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  136. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  137. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  138. package/hooks/hooks_constants/local_identity.py +59 -8
  139. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  140. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  141. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  142. package/hooks/hooks_constants/test_local_identity.py +105 -3
  143. package/hooks/pyproject.toml +52 -4
  144. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  145. package/hooks/validation/mypy_validator.py +2 -2
  146. package/hooks/validators/CLAUDE.md +1 -1
  147. package/hooks/validators/README.md +2 -0
  148. package/hooks/validators/health_check.py +1 -0
  149. package/hooks/validators/mypy_integration.py +2 -0
  150. package/hooks/validators/python_style_checks.py +114 -136
  151. package/hooks/validators/python_style_helpers.py +95 -0
  152. package/hooks/validators/ruff_integration.py +3 -0
  153. package/hooks/validators/test_python_style_checks.py +0 -164
  154. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  155. package/hooks/validators/test_python_style_fixes.py +251 -0
  156. package/hooks/validators/test_python_style_helpers.py +125 -0
  157. package/hooks/workflow/auto_formatter.py +5 -4
  158. package/package.json +1 -1
  159. package/rules/CLAUDE.md +1 -0
  160. package/rules/anti-corollary-tests.md +69 -0
  161. package/rules/bdd.md +1 -3
  162. package/rules/code-reviews.md +1 -1
  163. package/rules/gh-paginate.md +1 -1
  164. package/rules/plain-language.md +2 -0
  165. package/scripts/CLAUDE.md +4 -0
  166. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  167. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  168. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  169. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  170. package/scripts/grok_headless_runner.py +294 -0
  171. package/scripts/grok_worker_preflight.py +410 -0
  172. package/scripts/invoke_code_review.py +463 -0
  173. package/scripts/resolve_worker_spawn.py +619 -0
  174. package/scripts/spawn_grok_batch.py +672 -0
  175. package/scripts/test_grok_headless_runner.py +626 -0
  176. package/scripts/test_grok_worker_preflight.py +1054 -0
  177. package/scripts/test_invoke_code_review.py +672 -0
  178. package/scripts/test_resolve_worker_spawn.py +1014 -0
  179. package/scripts/test_spawn_grok_batch.py +1017 -0
  180. package/skills/CLAUDE.md +6 -3
  181. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  182. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  183. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  184. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  185. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  186. package/skills/autoconverge/CLAUDE.md +3 -3
  187. package/skills/autoconverge/SKILL.md +9 -3
  188. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  189. package/skills/autoconverge/reference/convergence.md +33 -11
  190. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  191. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  192. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  193. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  194. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  195. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  196. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  197. package/skills/autoconverge/workflow/converge.mjs +202 -18
  198. package/skills/bugteam/CLAUDE.md +2 -2
  199. package/skills/bugteam/CONSTRAINTS.md +3 -2
  200. package/skills/bugteam/PROMPTS.md +7 -6
  201. package/skills/bugteam/SKILL.md +18 -13
  202. package/skills/bugteam/reference/README.md +2 -3
  203. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  204. package/skills/bugteam/reference/design-rationale.md +1 -1
  205. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  206. package/skills/bugteam/reference/team-setup.md +8 -2
  207. package/skills/closeout/SKILL.md +153 -0
  208. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  209. package/skills/closeout/reference/issue-body-templates.md +108 -0
  210. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  211. package/skills/codex-review/CLAUDE.md +46 -0
  212. package/skills/codex-review/SKILL.md +181 -0
  213. package/skills/codex-review/reference/CLAUDE.md +15 -0
  214. package/skills/codex-review/reference/cli-contract.md +253 -0
  215. package/skills/codex-review/reference/loop-integration.md +118 -0
  216. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  217. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  218. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  219. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  220. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  221. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  222. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  223. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  224. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  225. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  226. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  227. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  228. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  229. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  230. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  231. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  232. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  233. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  234. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  235. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  236. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  237. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  238. package/skills/codex-review/test_skill_scaffold.py +192 -0
  239. package/skills/grok-spawn/CLAUDE.md +28 -0
  240. package/skills/grok-spawn/SKILL.md +226 -0
  241. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  242. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  243. package/skills/grokify/SKILL.md +9 -1
  244. package/skills/grokify/capability-claims.test.mjs +28 -0
  245. package/skills/grokify/evals/README.md +72 -0
  246. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  247. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  248. package/skills/orchestrator/SKILL.md +32 -22
  249. package/skills/orchestrator-refresh/SKILL.md +12 -8
  250. package/skills/pr-converge/CLAUDE.md +1 -1
  251. package/skills/pr-converge/SKILL.md +34 -13
  252. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  253. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  254. package/skills/pr-converge/reference/ground-rules.md +1 -1
  255. package/skills/pr-converge/reference/per-tick.md +130 -42
  256. package/skills/pr-converge/reference/state-schema.md +10 -0
  257. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  258. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  259. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  260. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  261. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  262. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  263. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  264. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  265. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  266. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  267. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  268. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  269. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  270. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  271. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  272. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  273. package/skills/pr-fix-protocol/SKILL.md +65 -0
  274. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  275. package/skills/reviewer-gates/SKILL.md +7 -5
  276. package/skills/skill-builder/references/skill-modularity.md +1 -1
  277. package/skills/team-advisor/SKILL.md +16 -12
  278. package/system-prompts/software-engineer.xml +7 -6
  279. package/hooks/validators/test_verify_paths.py +0 -32
  280. package/hooks/validators/verify_paths.py +0 -57
@@ -12,7 +12,8 @@ import logging
12
12
  import sys
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
15
- from typing import Iterator, List, Optional, Union
15
+
16
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
16
17
 
17
18
  try:
18
19
  from hooks_constants.python_style_checks_constants import (
@@ -20,7 +21,6 @@ try:
20
21
  MINIMUM_ARGUMENT_COUNT,
21
22
  )
22
23
  except ModuleNotFoundError:
23
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
24
24
  if _hooks_directory not in sys.path:
25
25
  sys.path.insert(0, _hooks_directory)
26
26
  from hooks_constants.python_style_checks_constants import (
@@ -28,14 +28,35 @@ except ModuleNotFoundError:
28
28
  MINIMUM_ARGUMENT_COUNT,
29
29
  )
30
30
 
31
+ try:
32
+ from validators.python_style_helpers import (
33
+ FunctionNode,
34
+ blank_line_for_source,
35
+ function_start_line,
36
+ gap_is_blank_only,
37
+ iter_function_definitions,
38
+ real_newline_lines,
39
+ top_level_functions,
40
+ )
41
+ except ModuleNotFoundError:
42
+ if _hooks_directory not in sys.path:
43
+ sys.path.insert(0, _hooks_directory)
44
+ from validators.python_style_helpers import (
45
+ FunctionNode,
46
+ blank_line_for_source,
47
+ function_start_line,
48
+ gap_is_blank_only,
49
+ iter_function_definitions,
50
+ real_newline_lines,
51
+ top_level_functions,
52
+ )
53
+
31
54
  logger = logging.getLogger(__name__)
32
55
 
33
56
  VIEW_SUFFIX = "_view"
34
57
  REQUEST_PARAM = "request"
35
58
  VIEWS_FILENAME = "views.py"
36
59
 
37
- FunctionNode = Union[ast.FunctionDef, ast.AsyncFunctionDef]
38
-
39
60
 
40
61
  @dataclass
41
62
  class Violation:
@@ -50,13 +71,13 @@ class Violation:
50
71
  return f"{self.file}:{self.line}: {self.message}"
51
72
 
52
73
 
53
- def check_imports_at_top(tree: ast.AST, filename: str) -> List[Violation]:
74
+ def check_imports_at_top(tree: ast.AST, filename: str) -> list[Violation]:
54
75
  """Check that all imports sit at the top of the file.
55
76
 
56
77
  Flags module-level imports that follow other statements and imports
57
78
  nested inside function or method bodies.
58
79
  """
59
- violations: List[Violation] = []
80
+ violations: list[Violation] = []
60
81
  violations.extend(_check_module_level_import_order(tree, filename))
61
82
  violations.extend(_check_no_inline_imports(tree, filename))
62
83
  return violations
@@ -75,11 +96,11 @@ def _is_docstring_statement(statement: ast.stmt) -> bool:
75
96
  return isinstance(literal, ast.Constant) and isinstance(literal.value, str)
76
97
 
77
98
 
78
- def _check_module_level_import_order(tree: ast.AST, filename: str) -> List[Violation]:
99
+ def _check_module_level_import_order(tree: ast.AST, filename: str) -> list[Violation]:
79
100
  """Flag module-level imports that appear after other statements."""
80
101
  if not isinstance(tree, ast.Module):
81
102
  return []
82
- violations: List[Violation] = []
103
+ violations: list[Violation] = []
83
104
  has_seen_non_import = False
84
105
  for each_statement in tree.body:
85
106
  is_import = _is_import_statement(each_statement)
@@ -96,17 +117,17 @@ def _check_module_level_import_order(tree: ast.AST, filename: str) -> List[Viola
96
117
  return violations
97
118
 
98
119
 
99
- def _check_no_inline_imports(tree: ast.AST, filename: str) -> List[Violation]:
120
+ def _check_no_inline_imports(tree: ast.AST, filename: str) -> list[Violation]:
100
121
  """Flag import statements located inside function or method bodies."""
101
- violations: List[Violation] = []
102
- for each_function_node in _iter_function_definitions(tree):
122
+ violations: list[Violation] = []
123
+ for each_function_node in iter_function_definitions(tree):
103
124
  violations.extend(_inline_imports_in(each_function_node, filename))
104
125
  return violations
105
126
 
106
127
 
107
- def _inline_imports_in(function_node: FunctionNode, filename: str) -> List[Violation]:
128
+ def _inline_imports_in(function_node: FunctionNode, filename: str) -> list[Violation]:
108
129
  """Return violations for imports nested inside a single function."""
109
- violations: List[Violation] = []
130
+ violations: list[Violation] = []
110
131
  for each_descendant in ast.walk(function_node):
111
132
  if isinstance(each_descendant, (ast.Import, ast.ImportFrom)):
112
133
  violations.append(
@@ -119,21 +140,14 @@ def _inline_imports_in(function_node: FunctionNode, filename: str) -> List[Viola
119
140
  return violations
120
141
 
121
142
 
122
- def _iter_function_definitions(tree: ast.AST) -> Iterator[FunctionNode]:
123
- """Yield every function and async-function definition in the tree."""
124
- for each_node in ast.walk(tree):
125
- if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
126
- yield each_node
127
-
128
-
129
- def check_no_empty_line_after_decorators(source: str, filename: str) -> List[Violation]:
143
+ def check_no_empty_line_after_decorators(source: str, filename: str) -> list[Violation]:
130
144
  """Check that no empty line separates a decorator from its function."""
131
- violations: List[Violation] = []
145
+ violations: list[Violation] = []
132
146
  try:
133
147
  tree = ast.parse(source)
134
148
  except SyntaxError:
135
149
  return violations
136
- for each_function_node in _iter_function_definitions(tree):
150
+ for each_function_node in iter_function_definitions(tree):
137
151
  violation = _decorator_gap_violation(each_function_node, filename)
138
152
  if violation is not None:
139
153
  violations.append(violation)
@@ -142,12 +156,13 @@ def check_no_empty_line_after_decorators(source: str, filename: str) -> List[Vio
142
156
 
143
157
  def _decorator_gap_violation(
144
158
  function_node: FunctionNode, filename: str
145
- ) -> Optional[Violation]:
159
+ ) -> Violation | None:
146
160
  """Return a violation when an empty line separates a decorator from its function."""
147
161
  if not function_node.decorator_list:
148
162
  return None
149
163
  last_decorator_line = max(
150
- each_decorator.lineno for each_decorator in function_node.decorator_list
164
+ each_decorator.end_lineno or each_decorator.lineno
165
+ for each_decorator in function_node.decorator_list
151
166
  )
152
167
  if function_node.lineno - last_decorator_line <= 1:
153
168
  return None
@@ -158,15 +173,13 @@ def _decorator_gap_violation(
158
173
  )
159
174
 
160
175
 
161
- def check_blank_lines_between_functions(
162
- source: str, filename: str
163
- ) -> List[Violation]:
176
+ def check_blank_lines_between_functions(source: str, filename: str) -> list[Violation]:
164
177
  """Check that consecutive top-level functions carry the expected blank gap."""
165
- violations: List[Violation] = []
166
- source_lines = _real_newline_lines(source)
167
- top_level_functions = _top_level_functions(source)
178
+ violations: list[Violation] = []
179
+ source_lines = real_newline_lines(source)
180
+ ordered_functions = top_level_functions(source)
168
181
  for each_current_function, each_next_function in zip(
169
- top_level_functions, top_level_functions[1:]
182
+ ordered_functions, ordered_functions[1:], strict=False
170
183
  ):
171
184
  violation = _spacing_violation(
172
185
  each_current_function, each_next_function, source_lines, filename
@@ -176,72 +189,12 @@ def check_blank_lines_between_functions(
176
189
  return violations
177
190
 
178
191
 
179
- def _top_level_functions(source: str) -> List[FunctionNode]:
180
- """Return the module's top-level function definitions, ordered by line."""
181
- try:
182
- tree = ast.parse(source)
183
- except SyntaxError:
184
- return []
185
- if not isinstance(tree, ast.Module):
186
- return []
187
- functions: List[FunctionNode] = [
188
- node
189
- for node in tree.body
190
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
191
- ]
192
- functions.sort(key=lambda function_node: function_node.lineno)
193
- return functions
194
-
195
-
196
- def _real_newline_lines(source: str) -> List[str]:
197
- """Split source on CR, LF, and CRLF only, keeping each line ending.
198
-
199
- ``str.splitlines`` breaks on form feed and other control characters that
200
- Python's line numbering ignores, so its indices drift from ``ast`` line
201
- numbers. This splitter keeps line indices aligned with ``ast`` line numbers.
202
- """
203
- lines: List[str] = []
204
- line_start = 0
205
- scan_index = 0
206
- total_length = len(source)
207
- while scan_index < total_length:
208
- character = source[scan_index]
209
- if character == "\r":
210
- scan_index += 1
211
- if scan_index < total_length and source[scan_index] == "\n":
212
- scan_index += 1
213
- elif character == "\n":
214
- scan_index += 1
215
- else:
216
- scan_index += 1
217
- continue
218
- lines.append(source[line_start:scan_index])
219
- line_start = scan_index
220
- if line_start < total_length:
221
- lines.append(source[line_start:])
222
- return lines
223
-
224
-
225
- def _function_start_line(function_node: FunctionNode) -> int:
226
- """Return the first source line of a function, counting its decorators."""
227
- if not function_node.decorator_list:
228
- return function_node.lineno
229
- return min(
230
- each_decorator.lineno for each_decorator in function_node.decorator_list
231
- )
232
-
233
-
234
- def _gap_is_blank_only(gap_lines: List[str]) -> bool:
235
- """Return True when every line between two functions is blank."""
236
- return all(each_line.strip() == "" for each_line in gap_lines)
237
-
238
-
239
192
  def _spacing_violation(
240
193
  current_function: FunctionNode,
241
194
  next_function: FunctionNode,
242
- source_lines: List[str],
195
+ all_source_lines: list[str],
243
196
  filename: str,
244
- ) -> Optional[Violation]:
197
+ ) -> Violation | None:
245
198
  """Return a violation when two functions hold the wrong blank-line count.
246
199
 
247
200
  A run of purely empty lines is measured; a run holding any other content
@@ -250,9 +203,9 @@ def _spacing_violation(
250
203
  current_end = current_function.end_lineno
251
204
  if current_end is None:
252
205
  return None
253
- next_start = _function_start_line(next_function)
254
- gap_lines = source_lines[current_end : next_start - 1]
255
- if not _gap_is_blank_only(gap_lines):
206
+ next_start = function_start_line(next_function)
207
+ gap_lines = all_source_lines[current_end : next_start - 1]
208
+ if not gap_is_blank_only(gap_lines):
256
209
  return None
257
210
  blank_line_count = len(gap_lines)
258
211
  if blank_line_count == EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS:
@@ -265,12 +218,12 @@ def _spacing_violation(
265
218
  )
266
219
 
267
220
 
268
- def check_view_function_naming(tree: ast.AST, filename: str) -> List[Violation]:
221
+ def check_view_function_naming(tree: ast.AST, filename: str) -> list[Violation]:
269
222
  """Check that request-handling functions in views.py end with _view."""
270
223
  if not filename.endswith(VIEWS_FILENAME):
271
224
  return []
272
- violations: List[Violation] = []
273
- for each_function_node in _iter_function_definitions(tree):
225
+ violations: list[Violation] = []
226
+ for each_function_node in iter_function_definitions(tree):
274
227
  if _is_misnamed_view(each_function_node):
275
228
  violations.append(
276
229
  Violation(
@@ -292,58 +245,84 @@ def _is_misnamed_view(function_node: FunctionNode) -> bool:
292
245
  return not function_node.name.endswith(VIEW_SUFFIX)
293
246
 
294
247
 
248
+ def _decorator_span_line_numbers(all_decorators: list[ast.expr]) -> set[int]:
249
+ """Return every source line a decorator expression occupies."""
250
+ occupied_lines: set[int] = set()
251
+ for each_decorator in all_decorators:
252
+ end_line = each_decorator.end_lineno or each_decorator.lineno
253
+ occupied_lines.update(range(each_decorator.lineno, end_line + 1))
254
+ return occupied_lines
255
+
256
+
257
+ def _blank_gap_lines_for_function(
258
+ function_node: FunctionNode, all_source_lines: list[str]
259
+ ) -> set[int]:
260
+ """Return blank line numbers between one function's decorators and its def."""
261
+ all_decorators = function_node.decorator_list
262
+ if not all_decorators:
263
+ return set()
264
+ occupied_lines = _decorator_span_line_numbers(all_decorators)
265
+ first_decorator_line = min(
266
+ each_decorator.lineno for each_decorator in all_decorators
267
+ )
268
+ return {
269
+ each_line_number
270
+ for each_line_number in range(first_decorator_line, function_node.lineno)
271
+ if each_line_number not in occupied_lines
272
+ and all_source_lines[each_line_number - 1].strip() == ""
273
+ }
274
+
275
+
295
276
  def fix_empty_lines_after_decorators(source: str) -> str:
296
- """Remove empty lines between decorators and their function definitions."""
297
- result_lines: List[str] = []
298
- should_skip_next_blank = False
299
- for each_line in source.splitlines(keepends=True):
300
- stripped = each_line.strip()
301
- if should_skip_next_blank and stripped == "":
302
- continue
303
- should_skip_next_blank = stripped.startswith("@")
304
- result_lines.append(each_line)
305
- return "".join(result_lines)
306
-
307
-
308
- def _blank_line_for_source(source: str) -> str:
309
- """Return the blank-line string matching the source newline convention.
310
-
311
- Every disk reader in this package loads source through
312
- Path.read_text(), whose universal-newline translation turns a bare
313
- \\r or a \\r\\n pair into \\n before the string reaches this function.
314
- A bare \\r never arrives from a file on disk. The \\r\\n branch below
315
- serves an in-memory caller that builds a CRLF string directly.
277
+ """Remove empty lines between decorators and their function definitions.
278
+
279
+ Blank lines separating stacked decorators, and blank lines between the
280
+ last line of a multi-line decorator and the def, are both removed.
316
281
  """
317
- if "\r\n" in source:
318
- return "\r\n"
319
- return "\n"
282
+ try:
283
+ tree = ast.parse(source)
284
+ except SyntaxError:
285
+ return source
286
+ source_lines = real_newline_lines(source)
287
+ blank_line_numbers: set[int] = set()
288
+ for each_function_node in iter_function_definitions(tree):
289
+ blank_line_numbers |= _blank_gap_lines_for_function(
290
+ each_function_node, source_lines
291
+ )
292
+ if not blank_line_numbers:
293
+ return source
294
+ return "".join(
295
+ each_line
296
+ for each_line_number, each_line in enumerate(source_lines, start=1)
297
+ if each_line_number not in blank_line_numbers
298
+ )
320
299
 
321
300
 
322
- def _normalized_gap(all_gap_lines: List[str], blank_line: str) -> List[str]:
301
+ def _normalized_gap(all_gap_lines: list[str], blank_line: str) -> list[str]:
323
302
  """Return the between-function gap normalized to the expected blank count.
324
303
 
325
304
  A gap that is entirely blank becomes exactly the expected number of blank
326
305
  lines. A gap that holds any non-blank line stays untouched so no comment,
327
306
  class, or statement in it is lost.
328
307
  """
329
- if not _gap_is_blank_only(all_gap_lines):
308
+ if not gap_is_blank_only(all_gap_lines):
330
309
  return all_gap_lines
331
310
  return [blank_line] * EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS
332
311
 
333
312
 
334
313
  def fix_function_spacing(source: str) -> str:
335
314
  """Normalize blank lines between top-level functions to exactly two."""
336
- functions = _top_level_functions(source)
315
+ functions = top_level_functions(source)
337
316
  if len(functions) <= 1:
338
317
  return source
339
- source_lines = _real_newline_lines(source)
340
- blank_line = _blank_line_for_source(source)
318
+ source_lines = real_newline_lines(source)
319
+ blank_line = blank_line_for_source(source)
341
320
  previous_end_line = functions[0].end_lineno
342
321
  if previous_end_line is None:
343
322
  return source
344
- rebuilt: List[str] = list(source_lines[:previous_end_line])
323
+ rebuilt: list[str] = list(source_lines[:previous_end_line])
345
324
  for each_next_function in functions[1:]:
346
- next_start_line = _function_start_line(each_next_function)
325
+ next_start_line = function_start_line(each_next_function)
347
326
  gap_lines = source_lines[previous_end_line : next_start_line - 1]
348
327
  rebuilt.extend(_normalized_gap(gap_lines, blank_line))
349
328
  next_end_line = each_next_function.end_lineno
@@ -358,9 +337,8 @@ def fix_function_spacing(source: str) -> str:
358
337
  def fix_file(file_path: Path) -> bool:
359
338
  """Apply the safe blank-line fixes and report whether the file changed.
360
339
 
361
- Reads the file through Path.read_text(), which applies
362
- universal-newline translation and normalizes every line ending to
363
- \\n before either fix function below inspects the text.
340
+ Path.read_text() normalizes every line ending to \\n before either fix
341
+ below inspects the text.
364
342
  """
365
343
  try:
366
344
  original = file_path.read_text(encoding="utf-8")
@@ -374,9 +352,9 @@ def fix_file(file_path: Path) -> bool:
374
352
  return True
375
353
 
376
354
 
377
- def validate_file(file_path: Path) -> List[Violation]:
355
+ def validate_file(file_path: Path) -> list[Violation]:
378
356
  """Validate a Python file against every style check."""
379
- violations: List[Violation] = []
357
+ violations: list[Violation] = []
380
358
  filename = str(file_path)
381
359
  try:
382
360
  source = file_path.read_text(encoding="utf-8")
@@ -403,7 +381,7 @@ def main() -> int:
403
381
  if len(sys.argv) < minimum_argument_count:
404
382
  logger.error("Usage: %s <file1.py> [file2.py ...]", Path(sys.argv[0]).name)
405
383
  return 1
406
- all_violations: List[Violation] = []
384
+ all_violations: list[Violation] = []
407
385
  for each_file_arg in sys.argv[1:]:
408
386
  file_path = Path(each_file_arg)
409
387
  if not file_path.exists():
@@ -0,0 +1,95 @@
1
+ """Shared source-line and function-discovery helpers for the style checks.
2
+
3
+ These pure helpers underlie the style checks and the blank-line fixers:
4
+ splitting source into ast-aligned lines, locating function definitions, and
5
+ matching the source newline convention.
6
+ """
7
+
8
+ import ast
9
+ from collections.abc import Iterator
10
+
11
+ FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
12
+
13
+
14
+ def iter_function_definitions(tree: ast.AST) -> Iterator[FunctionNode]:
15
+ """Yield every function and async-function definition in the tree."""
16
+ for each_node in ast.walk(tree):
17
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
18
+ yield each_node
19
+
20
+
21
+ def top_level_functions(source: str) -> list[FunctionNode]:
22
+ """Return the module's top-level function definitions, ordered by line."""
23
+ try:
24
+ tree = ast.parse(source)
25
+ except SyntaxError:
26
+ return []
27
+ if not isinstance(tree, ast.Module):
28
+ return []
29
+ functions: list[FunctionNode] = [
30
+ node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
31
+ ]
32
+ functions.sort(key=lambda function_node: function_node.lineno)
33
+ return functions
34
+
35
+
36
+ def function_start_line(function_node: FunctionNode) -> int:
37
+ """Return the first source line of a function, counting its decorators."""
38
+ if not function_node.decorator_list:
39
+ return function_node.lineno
40
+ return min(each_decorator.lineno for each_decorator in function_node.decorator_list)
41
+
42
+
43
+ def gap_is_blank_only(all_gap_lines: list[str]) -> bool:
44
+ """Return True when every line between two functions is blank."""
45
+ return all(each_line.strip() == "" for each_line in all_gap_lines)
46
+
47
+
48
+ def blank_line_for_source(source: str) -> str:
49
+ """Return the blank-line string matching the source newline convention.
50
+
51
+ Path.read_text() normalizes disk newlines to \\n before this runs, so the
52
+ CRLF branch serves an in-memory caller that builds a CRLF string directly.
53
+ """
54
+ if "\r\n" in source:
55
+ return "\r\n"
56
+ return "\n"
57
+
58
+
59
+ def _advance_past_newline(source: str, scan_index: int) -> int | None:
60
+ """Return the index past a CR, LF, or CRLF at scan_index, or None.
61
+
62
+ None marks a character that is not a line ending.
63
+ """
64
+ character = source[scan_index]
65
+ if character == "\r":
66
+ scan_index += 1
67
+ if scan_index < len(source) and source[scan_index] == "\n":
68
+ scan_index += 1
69
+ return scan_index
70
+ if character == "\n":
71
+ return scan_index + 1
72
+ return None
73
+
74
+
75
+ def real_newline_lines(source: str) -> list[str]:
76
+ """Split source on CR, LF, and CRLF only, keeping each line ending.
77
+
78
+ Line indices stay aligned with ast line numbers because the split
79
+ ignores form feed and other control characters ast does not count.
80
+ """
81
+ lines: list[str] = []
82
+ line_start = 0
83
+ scan_index = 0
84
+ total_length = len(source)
85
+ while scan_index < total_length:
86
+ line_end = _advance_past_newline(source, scan_index)
87
+ if line_end is None:
88
+ scan_index += 1
89
+ continue
90
+ scan_index = line_end
91
+ lines.append(source[line_start:scan_index])
92
+ line_start = scan_index
93
+ if line_start < total_length:
94
+ lines.append(source[line_start:])
95
+ return lines
@@ -17,6 +17,7 @@ def check_ruff_available() -> bool:
17
17
  try:
18
18
  result = subprocess.run(
19
19
  ["ruff", "--version"],
20
+ check=False,
20
21
  capture_output=True,
21
22
  text=True,
22
23
  )
@@ -39,6 +40,7 @@ def run_ruff_check(files: list[Path]) -> RuffResult:
39
40
 
40
41
  result = subprocess.run(
41
42
  ["ruff", "check"] + py_files,
43
+ check=False,
42
44
  capture_output=True,
43
45
  text=True,
44
46
  )
@@ -61,6 +63,7 @@ def run_ruff_fix(files: list[Path]) -> RuffResult:
61
63
 
62
64
  result = subprocess.run(
63
65
  ["ruff", "check", "--fix"] + py_files,
66
+ check=False,
64
67
  capture_output=True,
65
68
  text=True,
66
69
  )