claude-dev-env 1.92.0 → 1.93.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 (216) hide show
  1. package/CLAUDE.md +5 -39
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +7 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/rules/testing.md +0 -2
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +184 -0
  179. package/skills/orchestrator-refresh/SKILL.md +37 -0
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +32 -164
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
  216. package/skills/team-advisor-refresh/SKILL.md +0 -25
@@ -3,22 +3,39 @@
3
3
  Implements four style checks:
4
4
  1. Imports at top of file
5
5
  2. No empty lines after decorators
6
- 3. Single empty line between functions
6
+ 3. Two empty lines between top-level functions
7
7
  4. View functions end with _view suffix
8
8
  """
9
9
 
10
10
  import ast
11
+ import logging
11
12
  import sys
12
13
  from dataclasses import dataclass
13
14
  from pathlib import Path
14
- from typing import List
15
+ from typing import Iterator, List, Optional, Union
16
+
17
+ try:
18
+ from hooks_constants.python_style_checks_constants import (
19
+ EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS,
20
+ MINIMUM_ARGUMENT_COUNT,
21
+ )
22
+ except ModuleNotFoundError:
23
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
24
+ if _hooks_directory not in sys.path:
25
+ sys.path.insert(0, _hooks_directory)
26
+ from hooks_constants.python_style_checks_constants import (
27
+ EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS,
28
+ MINIMUM_ARGUMENT_COUNT,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
15
32
 
16
-
17
- # Constants
18
33
  VIEW_SUFFIX = "_view"
19
34
  REQUEST_PARAM = "request"
20
35
  VIEWS_FILENAME = "views.py"
21
36
 
37
+ FunctionNode = Union[ast.FunctionDef, ast.AsyncFunctionDef]
38
+
22
39
 
23
40
  @dataclass
24
41
  class Violation:
@@ -34,329 +51,367 @@ class Violation:
34
51
 
35
52
 
36
53
  def check_imports_at_top(tree: ast.AST, filename: str) -> List[Violation]:
37
- """Check that all imports are at the top of the file.
38
-
39
- Catches two violations:
40
- 1. Module-level imports after non-import statements
41
- 2. Imports inside functions/classes (inline imports)
54
+ """Check that all imports sit at the top of the file.
42
55
 
43
- Args:
44
- tree: AST tree to check
45
- filename: Name of file being checked
46
-
47
- Returns:
48
- List of violations found
56
+ Flags module-level imports that follow other statements and imports
57
+ nested inside function or method bodies.
49
58
  """
50
59
  violations: List[Violation] = []
60
+ violations.extend(_check_module_level_import_order(tree, filename))
61
+ violations.extend(_check_no_inline_imports(tree, filename))
62
+ return violations
63
+
51
64
 
52
- # Check 1: Module-level imports must be at top
53
- if isinstance(tree, ast.Module):
54
- seen_non_import = False
55
- for child in tree.body:
56
- if isinstance(child, (ast.Import, ast.ImportFrom)):
57
- if seen_non_import:
58
- violations.append(
59
- Violation(
60
- filename,
61
- child.lineno,
62
- "Import statement must be at top of file",
63
- )
64
- )
65
- elif isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
66
- # Allow docstrings at top
67
- if isinstance(child.value.value, str):
68
- continue
69
- seen_non_import = True
70
- else:
71
- seen_non_import = True
72
-
73
- # Check 2: No imports inside functions or methods
74
- for node in ast.walk(tree):
75
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
76
- for child in ast.walk(node):
77
- if isinstance(child, (ast.Import, ast.ImportFrom)):
78
- violations.append(
79
- Violation(
80
- filename,
81
- child.lineno,
82
- "Import inside function - move to top of file",
83
- )
84
- )
65
+ def _is_import_statement(statement: ast.stmt) -> bool:
66
+ """Return True when the statement is an import or from-import."""
67
+ return isinstance(statement, (ast.Import, ast.ImportFrom))
85
68
 
69
+
70
+ def _is_docstring_statement(statement: ast.stmt) -> bool:
71
+ """Return True when the statement is a string-literal docstring."""
72
+ if not isinstance(statement, ast.Expr):
73
+ return False
74
+ literal = statement.value
75
+ return isinstance(literal, ast.Constant) and isinstance(literal.value, str)
76
+
77
+
78
+ def _check_module_level_import_order(tree: ast.AST, filename: str) -> List[Violation]:
79
+ """Flag module-level imports that appear after other statements."""
80
+ if not isinstance(tree, ast.Module):
81
+ return []
82
+ violations: List[Violation] = []
83
+ has_seen_non_import = False
84
+ for each_statement in tree.body:
85
+ is_import = _is_import_statement(each_statement)
86
+ if is_import and has_seen_non_import:
87
+ violations.append(
88
+ Violation(
89
+ filename,
90
+ each_statement.lineno,
91
+ "Import statement must be at top of file",
92
+ )
93
+ )
94
+ if not is_import and not _is_docstring_statement(each_statement):
95
+ has_seen_non_import = True
86
96
  return violations
87
97
 
88
98
 
89
- def check_no_empty_line_after_decorators(source: str, filename: str) -> List[Violation]:
90
- """Check that decorators have no empty line before function.
99
+ def _check_no_inline_imports(tree: ast.AST, filename: str) -> List[Violation]:
100
+ """Flag import statements located inside function or method bodies."""
101
+ violations: List[Violation] = []
102
+ for each_function_node in _iter_function_definitions(tree):
103
+ violations.extend(_inline_imports_in(each_function_node, filename))
104
+ return violations
91
105
 
92
- Args:
93
- source: Source code as string
94
- filename: Name of file being checked
95
106
 
96
- Returns:
97
- List of violations found
98
- """
107
+ def _inline_imports_in(function_node: FunctionNode, filename: str) -> List[Violation]:
108
+ """Return violations for imports nested inside a single function."""
99
109
  violations: List[Violation] = []
100
- lines = source.splitlines()
110
+ for each_descendant in ast.walk(function_node):
111
+ if isinstance(each_descendant, (ast.Import, ast.ImportFrom)):
112
+ violations.append(
113
+ Violation(
114
+ filename,
115
+ each_descendant.lineno,
116
+ "Import inside function - move to top of file",
117
+ )
118
+ )
119
+ return violations
120
+
121
+
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
101
127
 
128
+
129
+ def check_no_empty_line_after_decorators(source: str, filename: str) -> List[Violation]:
130
+ """Check that no empty line separates a decorator from its function."""
131
+ violations: List[Violation] = []
102
132
  try:
103
133
  tree = ast.parse(source)
104
134
  except SyntaxError:
105
135
  return violations
106
-
107
- for node in ast.walk(tree):
108
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.decorator_list:
109
- # Get last decorator line
110
- last_decorator_line = max(d.lineno for d in node.decorator_list)
111
- function_line = node.lineno
112
-
113
- # Check if there's an empty line between decorator and function
114
- if function_line - last_decorator_line > 1:
115
- violations.append(
116
- Violation(
117
- filename,
118
- last_decorator_line,
119
- "No empty line allowed between decorator and function",
120
- )
121
- )
122
-
136
+ for each_function_node in _iter_function_definitions(tree):
137
+ violation = _decorator_gap_violation(each_function_node, filename)
138
+ if violation is not None:
139
+ violations.append(violation)
123
140
  return violations
124
141
 
125
142
 
126
- def check_single_empty_line_between_functions(
143
+ def _decorator_gap_violation(
144
+ function_node: FunctionNode, filename: str
145
+ ) -> Optional[Violation]:
146
+ """Return a violation when an empty line separates a decorator from its function."""
147
+ if not function_node.decorator_list:
148
+ return None
149
+ last_decorator_line = max(
150
+ each_decorator.lineno for each_decorator in function_node.decorator_list
151
+ )
152
+ if function_node.lineno - last_decorator_line <= 1:
153
+ return None
154
+ return Violation(
155
+ filename,
156
+ last_decorator_line,
157
+ "No empty line allowed between decorator and function",
158
+ )
159
+
160
+
161
+ def check_blank_lines_between_functions(
127
162
  source: str, filename: str
128
163
  ) -> List[Violation]:
129
- """Check that functions have exactly one empty line between them.
130
-
131
- Args:
132
- source: Source code as string
133
- filename: Name of file being checked
134
-
135
- Returns:
136
- List of violations found
137
- """
164
+ """Check that consecutive top-level functions carry the expected blank gap."""
138
165
  violations: List[Violation] = []
139
- lines = source.splitlines()
166
+ source_lines = _real_newline_lines(source)
167
+ top_level_functions = _top_level_functions(source)
168
+ for each_current_function, each_next_function in zip(
169
+ top_level_functions, top_level_functions[1:]
170
+ ):
171
+ violation = _spacing_violation(
172
+ each_current_function, each_next_function, source_lines, filename
173
+ )
174
+ if violation is not None:
175
+ violations.append(violation)
176
+ return violations
140
177
 
178
+
179
+ def _top_level_functions(source: str) -> List[FunctionNode]:
180
+ """Return the module's top-level function definitions, ordered by line."""
141
181
  try:
142
182
  tree = ast.parse(source)
143
183
  except SyntaxError:
144
- return violations
145
-
146
- # Get all top-level function definitions
147
- functions = [
148
- node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
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))
149
191
  ]
192
+ functions.sort(key=lambda function_node: function_node.lineno)
193
+ return functions
150
194
 
151
- # Filter to only top-level functions (not nested)
152
- if isinstance(tree, ast.Module):
153
- top_level_functions = [
154
- node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
155
- ]
156
- else:
157
- top_level_functions = []
158
-
159
- # Sort by line number
160
- top_level_functions.sort(key=lambda f: f.lineno)
161
-
162
- # Check spacing between consecutive functions
163
- for i in range(len(top_level_functions) - 1):
164
- current_func = top_level_functions[i]
165
- next_func = top_level_functions[i + 1]
166
-
167
- # Find last line of current function
168
- current_end = current_func.end_lineno
169
- next_start = next_func.lineno
170
-
171
- if current_end is not None:
172
- # Calculate empty lines between functions
173
- empty_lines = next_start - current_end - 1
174
-
175
- if empty_lines != 1:
176
- violations.append(
177
- Violation(
178
- filename,
179
- current_end,
180
- f"Expected 1 empty line between functions, found {empty_lines}",
181
- )
182
- )
183
195
 
184
- return violations
196
+ def _real_newline_lines(source: str) -> List[str]:
197
+ """Split source on CR, LF, and CRLF only, keeping each line ending.
185
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
186
223
 
187
- def check_view_function_naming(tree: ast.AST, filename: str) -> List[Violation]:
188
- """Check that view functions end with _view suffix.
189
224
 
190
- Only applies to functions in views.py that have 'request' as first parameter.
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
+ )
191
232
 
192
- Args:
193
- tree: AST tree to check
194
- filename: Name of file being checked
195
233
 
196
- Returns:
197
- List of violations found
198
- """
199
- violations: List[Violation] = []
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)
200
237
 
201
- # Only check files named views.py
202
- if not filename.endswith(VIEWS_FILENAME):
203
- return violations
204
238
 
205
- for node in ast.walk(tree):
206
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
207
- # Check if first parameter is 'request'
208
- if node.args.args and node.args.args[0].arg == REQUEST_PARAM:
209
- # Check if function name ends with _view
210
- if not node.name.endswith(VIEW_SUFFIX):
211
- violations.append(
212
- Violation(
213
- filename,
214
- node.lineno,
215
- f"View function '{node.name}' must end with '{VIEW_SUFFIX}'",
216
- )
217
- )
239
+ def _spacing_violation(
240
+ current_function: FunctionNode,
241
+ next_function: FunctionNode,
242
+ source_lines: List[str],
243
+ filename: str,
244
+ ) -> Optional[Violation]:
245
+ """Return a violation when two functions hold the wrong blank-line count.
218
246
 
219
- return violations
247
+ A run of purely empty lines is measured; a run holding any other content
248
+ is skipped, matching the fixer so a flagged file can be normalized.
249
+ """
250
+ current_end = current_function.end_lineno
251
+ if current_end is None:
252
+ 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):
256
+ return None
257
+ blank_line_count = len(gap_lines)
258
+ if blank_line_count == EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS:
259
+ return None
260
+ return Violation(
261
+ filename,
262
+ current_end,
263
+ f"Expected {EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS} empty lines "
264
+ f"between functions, found {blank_line_count}",
265
+ )
220
266
 
221
267
 
222
- def fix_empty_lines_after_decorators(source: str) -> str:
223
- """Remove empty lines between decorators and function definitions.
268
+ def check_view_function_naming(tree: ast.AST, filename: str) -> List[Violation]:
269
+ """Check that request-handling functions in views.py end with _view."""
270
+ if not filename.endswith(VIEWS_FILENAME):
271
+ return []
272
+ violations: List[Violation] = []
273
+ for each_function_node in _iter_function_definitions(tree):
274
+ if _is_misnamed_view(each_function_node):
275
+ violations.append(
276
+ Violation(
277
+ filename,
278
+ each_function_node.lineno,
279
+ f"View function '{each_function_node.name}' must end with '{VIEW_SUFFIX}'",
280
+ )
281
+ )
282
+ return violations
224
283
 
225
- Args:
226
- source: Source code as string
227
284
 
228
- Returns:
229
- Fixed source code
230
- """
231
- lines = source.splitlines(keepends=True)
232
- result_lines: List[str] = []
233
- skip_next_blank = False
285
+ def _is_misnamed_view(function_node: FunctionNode) -> bool:
286
+ """Return True when a request handler is missing the _view suffix."""
287
+ arguments = function_node.args.args
288
+ if not arguments:
289
+ return False
290
+ if arguments[0].arg != REQUEST_PARAM:
291
+ return False
292
+ return not function_node.name.endswith(VIEW_SUFFIX)
234
293
 
235
- for line in lines:
236
- stripped = line.strip()
237
294
 
238
- if stripped.startswith("@"):
239
- skip_next_blank = True
240
- result_lines.append(line)
241
- elif skip_next_blank and stripped == "":
295
+ 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 == "":
242
302
  continue
243
- else:
244
- skip_next_blank = False
245
- result_lines.append(line)
246
-
303
+ should_skip_next_blank = stripped.startswith("@")
304
+ result_lines.append(each_line)
247
305
  return "".join(result_lines)
248
306
 
249
307
 
250
- def fix_multiple_blank_lines(source: str) -> str:
251
- """Collapse multiple blank lines between functions to single blank line.
252
-
253
- Args:
254
- source: Source code as string
308
+ def _blank_line_for_source(source: str) -> str:
309
+ """Return the blank-line string matching the source newline convention.
255
310
 
256
- Returns:
257
- Fixed source code
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.
258
316
  """
259
- lines = source.splitlines(keepends=True)
260
- result_lines: List[str] = []
261
- blank_count = 0
317
+ if "\r\n" in source:
318
+ return "\r\n"
319
+ return "\n"
262
320
 
263
- for line in lines:
264
- if line.strip() == "":
265
- blank_count += 1
266
- if blank_count <= 1:
267
- result_lines.append(line)
268
- else:
269
- blank_count = 0
270
- result_lines.append(line)
271
321
 
272
- return "".join(result_lines)
322
+ def _normalized_gap(all_gap_lines: List[str], blank_line: str) -> List[str]:
323
+ """Return the between-function gap normalized to the expected blank count.
273
324
 
325
+ A gap that is entirely blank becomes exactly the expected number of blank
326
+ lines. A gap that holds any non-blank line stays untouched so no comment,
327
+ class, or statement in it is lost.
328
+ """
329
+ if not _gap_is_blank_only(all_gap_lines):
330
+ return all_gap_lines
331
+ return [blank_line] * EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS
332
+
333
+
334
+ def fix_function_spacing(source: str) -> str:
335
+ """Normalize blank lines between top-level functions to exactly two."""
336
+ functions = _top_level_functions(source)
337
+ if len(functions) <= 1:
338
+ return source
339
+ source_lines = _real_newline_lines(source)
340
+ blank_line = _blank_line_for_source(source)
341
+ previous_end_line = functions[0].end_lineno
342
+ if previous_end_line is None:
343
+ return source
344
+ rebuilt: List[str] = list(source_lines[:previous_end_line])
345
+ for each_next_function in functions[1:]:
346
+ next_start_line = _function_start_line(each_next_function)
347
+ gap_lines = source_lines[previous_end_line : next_start_line - 1]
348
+ rebuilt.extend(_normalized_gap(gap_lines, blank_line))
349
+ next_end_line = each_next_function.end_lineno
350
+ if next_end_line is None:
351
+ return source
352
+ rebuilt.extend(source_lines[next_start_line - 1 : next_end_line])
353
+ previous_end_line = next_end_line
354
+ rebuilt.extend(source_lines[previous_end_line:])
355
+ return "".join(rebuilt)
274
356
 
275
- def fix_file(file_path: Path) -> bool:
276
- """Apply all safe fixes to a file.
277
357
 
278
- Args:
279
- file_path: Path to file to fix
358
+ def fix_file(file_path: Path) -> bool:
359
+ """Apply the safe blank-line fixes and report whether the file changed.
280
360
 
281
- Returns:
282
- True if any fixes were applied, False otherwise
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.
283
364
  """
284
365
  try:
285
366
  original = file_path.read_text(encoding="utf-8")
286
367
  except Exception:
287
368
  return False
288
-
289
- fixed = original
290
- fixed = fix_empty_lines_after_decorators(fixed)
291
- fixed = fix_multiple_blank_lines(fixed)
292
-
293
- if fixed != original:
294
- file_path.write_text(fixed, encoding="utf-8")
295
- return True
296
-
297
- return False
369
+ decorators_fixed = fix_empty_lines_after_decorators(original)
370
+ blank_lines_fixed = fix_function_spacing(decorators_fixed)
371
+ if blank_lines_fixed == original:
372
+ return False
373
+ file_path.write_text(blank_lines_fixed, encoding="utf-8")
374
+ return True
298
375
 
299
376
 
300
377
  def validate_file(file_path: Path) -> List[Violation]:
301
- """Validate a Python file with all style checks.
302
-
303
- Args:
304
- file_path: Path to Python file to validate
305
-
306
- Returns:
307
- List of all violations found
308
- """
378
+ """Validate a Python file against every style check."""
309
379
  violations: List[Violation] = []
310
380
  filename = str(file_path)
311
-
312
381
  try:
313
382
  source = file_path.read_text(encoding="utf-8")
314
- except Exception as e:
315
- violations.append(Violation(filename, 0, f"Error reading file: {e}"))
383
+ except Exception as error:
384
+ violations.append(Violation(filename, 0, f"Error reading file: {error}"))
316
385
  return violations
317
-
318
386
  try:
319
387
  tree = ast.parse(source)
320
- except SyntaxError as e:
388
+ except SyntaxError as error:
321
389
  violations.append(
322
- Violation(filename, e.lineno or 0, f"Syntax error: {e.msg}")
390
+ Violation(filename, error.lineno or 0, f"Syntax error: {error.msg}")
323
391
  )
324
392
  return violations
325
-
326
- # Run all checks
327
393
  violations.extend(check_imports_at_top(tree, filename))
328
394
  violations.extend(check_no_empty_line_after_decorators(source, filename))
329
- violations.extend(check_single_empty_line_between_functions(source, filename))
395
+ violations.extend(check_blank_lines_between_functions(source, filename))
330
396
  violations.extend(check_view_function_naming(tree, filename))
331
-
332
397
  return violations
333
398
 
334
399
 
335
400
  def main() -> int:
336
- """Main entry point for command-line usage.
337
-
338
- Returns:
339
- Exit code: 0 if all files pass, 1 if violations found
340
- """
341
- if len(sys.argv) < 2:
342
- print("Usage: python_style_checks.py <file1.py> [file2.py ...]", file=sys.stderr)
401
+ """Run the style checks over the files named on the command line."""
402
+ minimum_argument_count = MINIMUM_ARGUMENT_COUNT
403
+ if len(sys.argv) < minimum_argument_count:
404
+ logger.error("Usage: %s <file1.py> [file2.py ...]", Path(sys.argv[0]).name)
343
405
  return 1
344
-
345
406
  all_violations: List[Violation] = []
346
-
347
- for file_arg in sys.argv[1:]:
348
- file_path = Path(file_arg)
407
+ for each_file_arg in sys.argv[1:]:
408
+ file_path = Path(each_file_arg)
349
409
  if not file_path.exists():
350
- print(f"Error: File not found: {file_path}", file=sys.stderr)
410
+ logger.error("File not found: %s", file_path)
351
411
  return 1
352
-
353
- violations = validate_file(file_path)
354
- all_violations.extend(violations)
355
-
356
- # Print all violations
357
- for violation in all_violations:
358
- print(violation)
359
-
412
+ all_violations.extend(validate_file(file_path))
413
+ for each_violation in all_violations:
414
+ logger.error("%s", each_violation)
360
415
  return 1 if all_violations else 0
361
416
 
362
417
 
@@ -32,6 +32,7 @@ _hooks_directory_on_path = str(hooks_dir.resolve())
32
32
  if _hooks_directory_on_path not in sys.path:
33
33
  sys.path.insert(0, _hooks_directory_on_path)
34
34
 
35
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
35
36
  from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
36
37
  apply_edits,
37
38
  edits_for_tool,
@@ -807,6 +808,11 @@ def _emit_pre_tool_use_deny(deny_reason: str) -> None:
807
808
  "permissionDecisionReason": deny_reason,
808
809
  }
809
810
  }
811
+ log_hook_block(
812
+ calling_hook_name="run_all_validators.py",
813
+ hook_event="PreToolUse",
814
+ block_reason=deny_reason,
815
+ )
810
816
  sys.stdout.write(json.dumps(deny_payload) + "\n")
811
817
  sys.stdout.flush()
812
818