claude-dev-env 1.50.0 → 1.50.2

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 (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. package/hooks/blocking/test_md_to_html_blocker.py +0 -810
@@ -1,4 +1,4 @@
1
- """Shared gh body-arg parsing utilities for blocking hooks."""
1
+ """Shared shell-token and gh body-arg parsing utilities for blocking hooks."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -55,6 +55,63 @@ _all_equals_prefixes_for_skip: tuple[str, ...] = tuple(
55
55
  bash_continuation_marker: str = "\\"
56
56
  powershell_continuation_marker: str = "`"
57
57
 
58
+ shell_variable_sigil: str = "$"
59
+ all_quote_characters: frozenset[str] = frozenset({'"', "'"})
60
+ minimum_meaningful_token_length: int = 2
61
+
62
+ non_body_value_flags: frozenset[str] = all_value_flags - {body_file_flag, body_file_short_flag}
63
+
64
+ _non_body_value_flag_equals_prefixes: tuple[str, ...] = tuple(
65
+ sorted((f"{each_flag}=" for each_flag in non_body_value_flags), key=len, reverse=True)
66
+ )
67
+
68
+
69
+ def is_flag_shaped_token(token: str) -> bool:
70
+ """Report whether a token is flag-shaped for body/PR-number extraction.
71
+
72
+ Treats any token whose second character is "-" as flag-shaped, so bare
73
+ "--" and "--<digit>" tokens both count as flags. `_is_flag_shaped` applies
74
+ a stricter rule for token-stream scanning.
75
+ """
76
+ if len(token) < minimum_meaningful_token_length:
77
+ return False
78
+ if not token.startswith("-"):
79
+ return False
80
+ return token[1] == "-" or token[1].isalpha()
81
+
82
+
83
+ def strip_surrounding_quotes(token: str) -> str:
84
+ if len(token) < minimum_meaningful_token_length:
85
+ return token
86
+ first_character = token[0]
87
+ last_character = token[-1]
88
+ if first_character in all_quote_characters and first_character == last_character:
89
+ return token[1:-1]
90
+ return token
91
+
92
+
93
+ def is_unresolvable_shell_value(token: str) -> bool:
94
+ return token.startswith(shell_variable_sigil)
95
+
96
+
97
+ def _match_prefix(token: str, all_prefixes: tuple[str, ...]) -> str | None:
98
+ for each_prefix in all_prefixes:
99
+ if token.startswith(each_prefix):
100
+ return each_prefix
101
+ return None
102
+
103
+
104
+ def match_body_flag_equals_prefix(token: str) -> str | None:
105
+ return _match_prefix(token, all_body_flag_prefixes)
106
+
107
+
108
+ def match_body_file_equals_prefix(token: str) -> str | None:
109
+ return _match_prefix(token, (body_file_flag_prefix, body_file_short_flag_prefix))
110
+
111
+
112
+ def match_non_body_value_flag_equals_prefix(token: str) -> str | None:
113
+ return _match_prefix(token, _non_body_value_flag_equals_prefixes)
114
+
58
115
 
59
116
  def _count_trailing_run(text: str, marker_character: str) -> int:
60
117
  trailing_run_length = 0
@@ -91,7 +148,13 @@ def get_logical_first_line(command: str) -> str:
91
148
 
92
149
 
93
150
  def _is_flag_shaped(token: str) -> bool:
94
- if len(token) < 2:
151
+ """Report whether a token is flag-shaped for token-stream scanning.
152
+
153
+ Requires an alphabetic character after "--", so bare "--" and "--<digit>"
154
+ tokens are not flag-shaped. `is_flag_shaped_token` applies a looser rule
155
+ for body/PR-number extraction.
156
+ """
157
+ if len(token) < minimum_meaningful_token_length:
95
158
  return False
96
159
  if not token.startswith("-"):
97
160
  return False
@@ -102,7 +165,7 @@ def _is_flag_shaped(token: str) -> bool:
102
165
 
103
166
 
104
167
  def _quoted_value_starts_split(value_token: str) -> bool:
105
- if len(value_token) < 2:
168
+ if len(value_token) < minimum_meaningful_token_length:
106
169
  return False
107
170
  first_character = value_token[0]
108
171
  if first_character not in {'"', "'"}:
@@ -129,13 +192,6 @@ def count_extra_tokens_to_skip_for_split_quoted_value(
129
192
  return None
130
193
 
131
194
 
132
- def _match_equals_prefix_for_skip(token: str) -> str | None:
133
- for each_prefix in _all_equals_prefixes_for_skip:
134
- if token.startswith(each_prefix):
135
- return each_prefix
136
- return None
137
-
138
-
139
195
  def iter_significant_tokens(
140
196
  command: str,
141
197
  pre_tokenized: tuple[str, list[str]] | None = None,
@@ -175,7 +231,7 @@ def iter_significant_tokens(
175
231
  while token_index < len(all_tokens):
176
232
  current_token = all_tokens[token_index]
177
233
  remaining_tokens = all_tokens[token_index + 1:]
178
- matched_equals_prefix = _match_equals_prefix_for_skip(current_token)
234
+ matched_equals_prefix = _match_prefix(current_token, _all_equals_prefixes_for_skip)
179
235
  if matched_equals_prefix is not None:
180
236
  value_token = current_token[len(matched_equals_prefix):]
181
237
  split_value_extra_tokens = count_extra_tokens_to_skip_for_split_quoted_value(
@@ -0,0 +1,65 @@
1
+ """Shared subprocess-invocation helpers for the md_to_html_blocker test suites.
2
+
3
+ Subprocess CWD is rooted in a per-session sandbox created lazily so that
4
+ relative-path test cases canonicalize outside any `.claude-plugin/` ancestor,
5
+ outside the OS temp directory, and outside the exempt home-relative
6
+ subdirectories. The sandbox is a real repo root (it carries a `.git` marker) so
7
+ relative `README.md` / `CHANGELOG.md` writes exercise the repo-root exemption
8
+ path. This keeps the suites independent of where pytest itself is run.
9
+ """
10
+
11
+ import functools
12
+ import json
13
+ import os
14
+ import shutil
15
+ import stat
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "md_to_html_blocker.py")
22
+
23
+
24
+ def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
25
+ try:
26
+ os.chmod(target_path, stat.S_IWRITE)
27
+ removal_function(target_path)
28
+ except OSError:
29
+ pass
30
+
31
+
32
+ def _force_rmtree(target_path: str) -> None:
33
+ handler_kw = (
34
+ {"onexc": _strip_read_only_and_retry}
35
+ if sys.version_info >= (3, 12)
36
+ else {"onerror": _strip_read_only_and_retry}
37
+ )
38
+ try:
39
+ shutil.rmtree(target_path, **handler_kw)
40
+ except OSError:
41
+ pass
42
+
43
+
44
+ @functools.lru_cache(maxsize=1)
45
+ def _get_sandbox_parent_directory() -> str:
46
+ sandbox_parent = tempfile.mkdtemp(prefix="pytest_md_blocker_", dir=str(Path.home()))
47
+ git_marker_path = os.path.join(sandbox_parent, ".git")
48
+ Path(git_marker_path).touch()
49
+ return sandbox_parent
50
+
51
+
52
+ class _RunHook:
53
+ def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
54
+ payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
55
+ return subprocess.run(
56
+ [sys.executable, HOOK_SCRIPT_PATH],
57
+ input=payload,
58
+ capture_output=True,
59
+ text=True,
60
+ check=False,
61
+ cwd=_get_sandbox_parent_directory(),
62
+ )
63
+
64
+
65
+ _run_hook = _RunHook()
@@ -0,0 +1,167 @@
1
+ """Parameter-annotation, return-annotation, and function-length checks."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _blocking_directory = str(Path(__file__).resolve().parent)
8
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
9
+ if _blocking_directory not in sys.path:
10
+ sys.path.insert(0, _blocking_directory)
11
+ if _hooks_directory not in sys.path:
12
+ sys.path.insert(0, _hooks_directory)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ _collect_annotated_arguments,
16
+ _definition_docstring_line_span,
17
+ _function_definition_line_span,
18
+ _scope_violations_to_changed_lines,
19
+ is_hook_infrastructure,
20
+ is_migration_file,
21
+ is_test_file,
22
+ is_workflow_registry_file,
23
+ )
24
+
25
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
26
+ ALL_SELF_AND_CLS_PARAMETER_NAMES,
27
+ FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
28
+ FUNCTION_LENGTH_BLOCKING_THRESHOLD,
29
+ )
30
+
31
+
32
+ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
33
+ if is_test_file(file_path):
34
+ return []
35
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
36
+ return []
37
+ try:
38
+ tree = ast.parse(content)
39
+ except SyntaxError:
40
+ return []
41
+ issues: list[str] = []
42
+ for each_node in ast.walk(tree):
43
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
44
+ continue
45
+ for each_arg in _collect_annotated_arguments(each_node):
46
+ if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
47
+ continue
48
+ if each_arg.annotation is None:
49
+ issues.append(
50
+ f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on {each_node.name!r} missing type annotation (CODE_RULES §6)"
51
+ )
52
+ return issues
53
+
54
+
55
+ def check_return_annotations(content: str, file_path: str) -> list[str]:
56
+ if is_test_file(file_path):
57
+ return []
58
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
59
+ return []
60
+ try:
61
+ tree = ast.parse(content)
62
+ except SyntaxError:
63
+ return []
64
+ issues: list[str] = []
65
+ for each_node in ast.walk(tree):
66
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
67
+ continue
68
+ if each_node.returns is None:
69
+ issues.append(
70
+ f"Line {each_node.lineno}: function {each_node.name!r} missing return type annotation (CODE_RULES §6)"
71
+ )
72
+ return issues
73
+
74
+
75
+ def check_function_length(
76
+ content: str,
77
+ file_path: str,
78
+ all_changed_lines: set[int] | None = None,
79
+ defer_scope_to_caller: bool = False,
80
+ ) -> list[str]:
81
+ """Flag functions whose executable span exceeds cognitive-load thresholds.
82
+
83
+ Function executable spans — the definition span (signature line through
84
+ last body statement, inclusive) minus the leading docstring lines of the
85
+ function and of every function or class nested within it, per
86
+ ``_definition_docstring_line_span`` summed over the nested definitions —
87
+ at or above ``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` appear in
88
+ the returned issues list and block the write at the
89
+ gate. The threshold rests on the small-function guidance in Robert C.
90
+ Martin, *Clean Code* Chapter Three ("Functions") and the Google Python Style
91
+ Guide's ~forty-line function review hint
92
+ (https://google.github.io/styleguide/pyguide.html) — a measure of
93
+ executable complexity, paired with the Guide's complete-docstring mandate
94
+ for public APIs, so documentation lines never count against the gate; this
95
+ gate blocks on body growth that pushes a function past that span. It does
96
+ not derive from CODE_RULES file-length guidance, which governs advisory
97
+ file-length signals and argues against hard numeric blocks.
98
+
99
+ The issue message carries ``Function NAME (defined at line X) is Y lines``
100
+ precisely so the gate's ``function_length_span_range`` can recover the
101
+ function's full declared span (lines ``X`` through ``X + Y - 1``). The
102
+ gate classifies the violation blocking when that span intersects the
103
+ diff's added lines — the body grew this diff — and advisory otherwise — a
104
+ pre-existing, untouched long function in a file the diff happened to
105
+ touch. Anchoring to the span rather than a single ``Line N:`` definition
106
+ line lets body growth on any interior line block correctly even when the
107
+ ``def`` line itself is untouched.
108
+
109
+ Exempt: test files (test bodies are sometimes long by necessity), Django
110
+ migrations (auto-generated), workflow registries (registry entries), and
111
+ hook infrastructure.
112
+
113
+ Args:
114
+ content: The Python source to analyze.
115
+ file_path: The path of the file being checked.
116
+ all_changed_lines: Post-edit line numbers the current edit touched, or
117
+ None to treat the whole file as in scope. When provided, a violation
118
+ blocks only when the function's declared span intersects the changed
119
+ lines.
120
+ defer_scope_to_caller: When True, return every violation so the
121
+ commit/push gate's ``split_violations_by_scope`` can scope by added
122
+ line and report the in-scope set.
123
+
124
+ Returns:
125
+ Blocking issues. When *defer_scope_to_caller* is True every violation is
126
+ returned for the gate to scope; otherwise every violation in scope is
127
+ returned.
128
+ """
129
+ if is_test_file(file_path):
130
+ return []
131
+ if is_hook_infrastructure(file_path):
132
+ return []
133
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
134
+ return []
135
+
136
+ try:
137
+ parsed_tree = ast.parse(content)
138
+ except SyntaxError:
139
+ return []
140
+
141
+ all_violations_in_walk_order: list[tuple[range, str]] = []
142
+ for each_node in ast.walk(parsed_tree):
143
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
144
+ continue
145
+ line_span = _function_definition_line_span(each_node)
146
+ if line_span < FUNCTION_LENGTH_BLOCKING_THRESHOLD:
147
+ continue
148
+ docstring_line_total = sum(
149
+ _definition_docstring_line_span(each_definition)
150
+ for each_definition in ast.walk(each_node)
151
+ if isinstance(
152
+ each_definition, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
153
+ )
154
+ )
155
+ executable_line_span = line_span - docstring_line_total
156
+ if executable_line_span >= FUNCTION_LENGTH_BLOCKING_THRESHOLD:
157
+ span_range = range(each_node.lineno, each_node.lineno + line_span)
158
+ message = (
159
+ f"Function {each_node.name!r} (defined at line {each_node.lineno}) "
160
+ f"is {line_span} lines - {FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX}"
161
+ )
162
+ all_violations_in_walk_order.append((span_range, message))
163
+ return _scope_violations_to_changed_lines(
164
+ all_violations_in_walk_order,
165
+ all_changed_lines,
166
+ defer_scope_to_caller,
167
+ )