claude-dev-env 1.50.0 → 1.50.1
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.
- package/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_enforcer.py +63 -21
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +136 -5
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/package.json +1 -1
- package/hooks/blocking/test_md_to_html_blocker.py +0 -810
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Detect body flags and recover the positional PR number from a gh command.
|
|
2
|
+
|
|
3
|
+
Reports whether a captured shell command carries any body or body-file flag,
|
|
4
|
+
and extracts the positional PR number (bare integer or GitHub PR URL) from a
|
|
5
|
+
gh pr edit/comment command while skipping value-taking flags and their values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _hooks_dir not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_dir)
|
|
16
|
+
|
|
17
|
+
from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
18
|
+
all_body_flags,
|
|
19
|
+
body_file_flag,
|
|
20
|
+
body_file_short_flag,
|
|
21
|
+
count_extra_tokens_to_skip_for_split_quoted_value,
|
|
22
|
+
get_logical_first_line,
|
|
23
|
+
is_flag_shaped_token,
|
|
24
|
+
is_unresolvable_shell_value,
|
|
25
|
+
match_body_file_equals_prefix,
|
|
26
|
+
match_body_flag_equals_prefix,
|
|
27
|
+
match_non_body_value_flag_equals_prefix,
|
|
28
|
+
non_body_value_flags,
|
|
29
|
+
strip_surrounding_quotes,
|
|
30
|
+
)
|
|
31
|
+
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
32
|
+
GH_PR_COMMAND_MIN_TOKEN_COUNT,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_positional_pr_number(token: str) -> int | None:
|
|
37
|
+
"""Return the PR number named by a positional token, or None if it is not one.
|
|
38
|
+
|
|
39
|
+
Accepts either a bare integer literal or a GitHub PR URL whose final path
|
|
40
|
+
segment is ``/pull/<number>``. The token may carry surrounding quotes;
|
|
41
|
+
unresolvable shell variables are rejected.
|
|
42
|
+
"""
|
|
43
|
+
stripped_candidate = strip_surrounding_quotes(token)
|
|
44
|
+
if is_unresolvable_shell_value(stripped_candidate):
|
|
45
|
+
return None
|
|
46
|
+
url_match = re.match(
|
|
47
|
+
r"^https?://[^/]+/[^/]+/[^/]+/pull/(\d+)(?:[/?#].*)?$",
|
|
48
|
+
stripped_candidate,
|
|
49
|
+
)
|
|
50
|
+
if url_match is not None:
|
|
51
|
+
try:
|
|
52
|
+
return int(url_match.group(1))
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return int(stripped_candidate)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_pr_number_from_command(command: str) -> int | None:
|
|
62
|
+
"""Return the PR number positional argument from a `gh pr edit|comment` command.
|
|
63
|
+
|
|
64
|
+
Skips value-taking non-body flags (and their value tokens) so that ``--repo owner/r``
|
|
65
|
+
pairs do not consume the trailing PR number. Accepts both a bare integer literal
|
|
66
|
+
and a GitHub PR URL (``https://github.com/o/r/pull/<n>``) in the positional slot.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
command: The raw shell command captured by the hook.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The PR number when one positional value (integer or URL) is present, else None.
|
|
73
|
+
"""
|
|
74
|
+
logical_line = get_logical_first_line(command)
|
|
75
|
+
if not logical_line:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
all_tokens = shlex.split(logical_line, posix=False)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
if len(all_tokens) < GH_PR_COMMAND_MIN_TOKEN_COUNT:
|
|
82
|
+
return None
|
|
83
|
+
if all_tokens[0] != "gh" or all_tokens[1] != "pr":
|
|
84
|
+
return None
|
|
85
|
+
subcommand_token = all_tokens[2]
|
|
86
|
+
if subcommand_token not in {"edit", "comment"}:
|
|
87
|
+
return None
|
|
88
|
+
all_value_taking_bare_flags: frozenset[str] = (
|
|
89
|
+
non_body_value_flags | all_body_flags | {body_file_flag, body_file_short_flag}
|
|
90
|
+
)
|
|
91
|
+
token_index = GH_PR_COMMAND_MIN_TOKEN_COUNT
|
|
92
|
+
while token_index < len(all_tokens):
|
|
93
|
+
current_token = all_tokens[token_index]
|
|
94
|
+
matched_equals_prefix = (
|
|
95
|
+
match_non_body_value_flag_equals_prefix(current_token)
|
|
96
|
+
or match_body_flag_equals_prefix(current_token)
|
|
97
|
+
or match_body_file_equals_prefix(current_token)
|
|
98
|
+
)
|
|
99
|
+
if matched_equals_prefix is not None:
|
|
100
|
+
first_value_token = current_token[len(matched_equals_prefix) :]
|
|
101
|
+
remaining_raw_tokens = all_tokens[token_index + 1 :]
|
|
102
|
+
extra_skip = (
|
|
103
|
+
count_extra_tokens_to_skip_for_split_quoted_value(
|
|
104
|
+
remaining_raw_tokens, first_value_token
|
|
105
|
+
)
|
|
106
|
+
or 0
|
|
107
|
+
)
|
|
108
|
+
token_index += 1 + extra_skip
|
|
109
|
+
continue
|
|
110
|
+
if current_token in all_value_taking_bare_flags:
|
|
111
|
+
token_index += 1
|
|
112
|
+
if token_index < len(all_tokens):
|
|
113
|
+
token_index += 1
|
|
114
|
+
continue
|
|
115
|
+
if is_flag_shaped_token(current_token):
|
|
116
|
+
token_index += 1
|
|
117
|
+
continue
|
|
118
|
+
resolved_pr_number = _resolve_positional_pr_number(current_token)
|
|
119
|
+
if resolved_pr_number is not None:
|
|
120
|
+
return resolved_pr_number
|
|
121
|
+
return None
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _command_carries_body_flag(command: str) -> bool:
|
|
126
|
+
"""Return True when the command string carries any body or body-file flag.
|
|
127
|
+
|
|
128
|
+
Detects the body/body-file forms accepted by ``gh pr {create,edit,comment}``:
|
|
129
|
+
|
|
130
|
+
- Long flags: a single ``"--body" in command`` substring check catches
|
|
131
|
+
every long form — ``--body``, ``--body=<value>``, ``--body-file``, and
|
|
132
|
+
``--body-file=<value>`` — because ``--body`` is a prefix of
|
|
133
|
+
``--body-file``. No separate ``--body-file`` check is needed.
|
|
134
|
+
- Short flags, space-separated: ``-b <value>``, ``-F <value>`` — matched
|
|
135
|
+
as `` -b `` and `` -F `` so the literal substring cannot collide with a
|
|
136
|
+
surrounding token (e.g. ``-base``, ``-Foo``).
|
|
137
|
+
- Short flags, equal-attached: ``-b=<value>``, ``-F=<value>`` — matched
|
|
138
|
+
as `` -b=`` and `` -F=`` for the same anti-collision reason. The test
|
|
139
|
+
suite relies on this detection path.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
command: The raw shell command captured by the hook.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if any documented body or body-file flag appears in the command.
|
|
146
|
+
"""
|
|
147
|
+
return (
|
|
148
|
+
"--body" in command
|
|
149
|
+
or " -b " in command
|
|
150
|
+
or " -b=" in command
|
|
151
|
+
or " -F " in command
|
|
152
|
+
or " -F=" in command
|
|
153
|
+
)
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Score PR body readability and manage its persisted strike/threshold state.
|
|
2
|
+
|
|
3
|
+
Computes Flesch Reading Ease and sentence-length metrics over the intro and
|
|
4
|
+
first section of a PR body, escalates repeated readability failures through a
|
|
5
|
+
persisted strike counter, applies the loosen/reset/enable/disable threshold
|
|
6
|
+
overrides, and dispatches the readability-management CLI flags.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _hooks_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _hooks_dir)
|
|
20
|
+
|
|
21
|
+
from blocking.pr_description_body_audit import strip_markdown_ceremony # noqa: E402
|
|
22
|
+
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
23
|
+
ATOMIC_WRITE_TEMP_SUFFIX,
|
|
24
|
+
DEFAULT_READABILITY_THRESHOLDS,
|
|
25
|
+
FENCED_CODE_BLOCK_PATTERN,
|
|
26
|
+
FLESCH_BASE_SCORE,
|
|
27
|
+
FLESCH_PERFECT_SCORE,
|
|
28
|
+
FLESCH_SYLLABLES_PER_WORD_COEFFICIENT,
|
|
29
|
+
FLESCH_WORDS_PER_SENTENCE_COEFFICIENT,
|
|
30
|
+
HEADING_LINE_PATTERN,
|
|
31
|
+
READABILITY_AVG_SENTENCE_WORDS_CEILING,
|
|
32
|
+
READABILITY_ENABLED_STATE_FILE,
|
|
33
|
+
READABILITY_FLESCH_LOOSEN_FACTOR,
|
|
34
|
+
READABILITY_LOOSEN_CAP,
|
|
35
|
+
READABILITY_MAX_SENTENCE_WORDS_CEILING,
|
|
36
|
+
READABILITY_MIN_FLESCH_FLOOR,
|
|
37
|
+
READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR,
|
|
38
|
+
READABILITY_STATE_FILE,
|
|
39
|
+
READABILITY_THRESHOLD_OVERRIDE_FILE,
|
|
40
|
+
ReadabilityThresholds,
|
|
41
|
+
)
|
|
42
|
+
from hooks_constants.setup_project_paths_constants import UTF8_ENCODING # noqa: E402
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _atomic_write_json(target_path: Path, all_payload_fields: dict[str, object]) -> None:
|
|
46
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
temporary_path = target_path.with_suffix(target_path.suffix + ATOMIC_WRITE_TEMP_SUFFIX)
|
|
48
|
+
with open(temporary_path, "w", encoding=UTF8_ENCODING) as write_handle:
|
|
49
|
+
json.dump(all_payload_fields, write_handle)
|
|
50
|
+
os.replace(temporary_path, target_path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_json_or_default(
|
|
54
|
+
target_path: Path, all_default_payload_fields: dict[str, object]
|
|
55
|
+
) -> dict[str, object]:
|
|
56
|
+
if not target_path.exists():
|
|
57
|
+
return dict(all_default_payload_fields)
|
|
58
|
+
try:
|
|
59
|
+
with open(target_path, "r", encoding=UTF8_ENCODING) as read_handle:
|
|
60
|
+
loaded_payload = json.load(read_handle)
|
|
61
|
+
except (FileNotFoundError, PermissionError, OSError, json.JSONDecodeError):
|
|
62
|
+
return dict(all_default_payload_fields)
|
|
63
|
+
if not isinstance(loaded_payload, dict):
|
|
64
|
+
return dict(all_default_payload_fields)
|
|
65
|
+
return loaded_payload
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_strike_count() -> int:
|
|
69
|
+
payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
|
|
70
|
+
raw_count = payload.get("strikes", 0)
|
|
71
|
+
if isinstance(raw_count, int) and not isinstance(raw_count, bool):
|
|
72
|
+
return max(raw_count, 0)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _increment_strike_count() -> int:
|
|
77
|
+
payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
|
|
78
|
+
raw_count = payload.get("strikes", 0)
|
|
79
|
+
is_valid_integer = isinstance(raw_count, int) and not isinstance(raw_count, bool)
|
|
80
|
+
starting_count = max(raw_count, 0) if is_valid_integer else 0
|
|
81
|
+
new_count = starting_count + 1
|
|
82
|
+
_atomic_write_json(READABILITY_STATE_FILE, {"strikes": new_count})
|
|
83
|
+
return new_count
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _reset_strike_count() -> None:
|
|
87
|
+
_atomic_write_json(READABILITY_STATE_FILE, {"strikes": 0})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_readability_thresholds() -> ReadabilityThresholds:
|
|
91
|
+
payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
|
|
92
|
+
flesch_min_value = payload.get("flesch_min", DEFAULT_READABILITY_THRESHOLDS.flesch_min)
|
|
93
|
+
max_sentence_value = payload.get(
|
|
94
|
+
"max_sentence_words", DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
|
|
95
|
+
)
|
|
96
|
+
avg_sentence_value = payload.get(
|
|
97
|
+
"avg_sentence_words", DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
|
|
98
|
+
)
|
|
99
|
+
flesch_is_int = isinstance(flesch_min_value, int) and not isinstance(flesch_min_value, bool)
|
|
100
|
+
max_is_int = isinstance(max_sentence_value, int) and not isinstance(max_sentence_value, bool)
|
|
101
|
+
avg_is_int = isinstance(avg_sentence_value, int) and not isinstance(avg_sentence_value, bool)
|
|
102
|
+
resolved_flesch = (
|
|
103
|
+
flesch_min_value if flesch_is_int else DEFAULT_READABILITY_THRESHOLDS.flesch_min
|
|
104
|
+
)
|
|
105
|
+
resolved_max = (
|
|
106
|
+
max_sentence_value if max_is_int else DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
|
|
107
|
+
)
|
|
108
|
+
resolved_avg = (
|
|
109
|
+
avg_sentence_value if avg_is_int else DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
|
|
110
|
+
)
|
|
111
|
+
return ReadabilityThresholds(
|
|
112
|
+
flesch_min=resolved_flesch,
|
|
113
|
+
max_sentence_words=resolved_max,
|
|
114
|
+
avg_sentence_words=resolved_avg,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_loosens_used() -> int:
|
|
119
|
+
payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
|
|
120
|
+
raw_count = payload.get("loosens_used", 0)
|
|
121
|
+
if isinstance(raw_count, int) and not isinstance(raw_count, bool):
|
|
122
|
+
return max(raw_count, 0)
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_readability_enabled() -> bool:
|
|
127
|
+
payload = _read_json_or_default(READABILITY_ENABLED_STATE_FILE, {"enabled": True})
|
|
128
|
+
enabled_value = payload.get("enabled", True)
|
|
129
|
+
if isinstance(enabled_value, bool):
|
|
130
|
+
return enabled_value
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _set_readability_enabled(enabled: bool) -> None:
|
|
135
|
+
_atomic_write_json(READABILITY_ENABLED_STATE_FILE, {"enabled": enabled})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _count_syllables_in_word(word: str) -> int:
|
|
139
|
+
all_vowel_characters: frozenset[str] = frozenset("aeiouy")
|
|
140
|
+
cleaned_word = "".join(
|
|
141
|
+
each_character for each_character in word.lower() if each_character.isalpha()
|
|
142
|
+
)
|
|
143
|
+
if not cleaned_word:
|
|
144
|
+
return 0
|
|
145
|
+
syllable_count = 0
|
|
146
|
+
is_previous_character_vowel = False
|
|
147
|
+
for each_character in cleaned_word:
|
|
148
|
+
is_vowel = each_character in all_vowel_characters
|
|
149
|
+
if is_vowel and not is_previous_character_vowel:
|
|
150
|
+
syllable_count += 1
|
|
151
|
+
is_previous_character_vowel = is_vowel
|
|
152
|
+
if cleaned_word.endswith("e") and syllable_count > 1:
|
|
153
|
+
syllable_count -= 1
|
|
154
|
+
return max(syllable_count, 1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _split_sentences(text: str) -> list[str]:
|
|
158
|
+
sentence_split_pattern = re.compile(r"[.!?]+\s+")
|
|
159
|
+
cleaned_text = text.strip()
|
|
160
|
+
if not cleaned_text:
|
|
161
|
+
return []
|
|
162
|
+
raw_pieces = sentence_split_pattern.split(cleaned_text)
|
|
163
|
+
all_sentences = [each_piece.strip() for each_piece in raw_pieces if each_piece.strip()]
|
|
164
|
+
return all_sentences
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _compute_flesch_reading_ease(text: str) -> float:
|
|
168
|
+
all_sentences = _split_sentences(text)
|
|
169
|
+
if not all_sentences:
|
|
170
|
+
return FLESCH_PERFECT_SCORE
|
|
171
|
+
all_words: list[str] = []
|
|
172
|
+
total_syllables = 0
|
|
173
|
+
for each_sentence in all_sentences:
|
|
174
|
+
sentence_words = [
|
|
175
|
+
each_token for each_token in re.split(r"\s+", each_sentence) if each_token
|
|
176
|
+
]
|
|
177
|
+
all_words.extend(sentence_words)
|
|
178
|
+
for each_word in sentence_words:
|
|
179
|
+
total_syllables += _count_syllables_in_word(each_word)
|
|
180
|
+
total_words = len(all_words)
|
|
181
|
+
if total_words == 0:
|
|
182
|
+
return FLESCH_PERFECT_SCORE
|
|
183
|
+
total_sentences = len(all_sentences)
|
|
184
|
+
return (
|
|
185
|
+
FLESCH_BASE_SCORE
|
|
186
|
+
- FLESCH_WORDS_PER_SENTENCE_COEFFICIENT * (total_words / total_sentences)
|
|
187
|
+
- FLESCH_SYLLABLES_PER_WORD_COEFFICIENT * (total_syllables / total_words)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_readability_target_text(body: str) -> str:
|
|
192
|
+
"""Return the ceremony-stripped prose window scored for readability.
|
|
193
|
+
|
|
194
|
+
Strips fenced code blocks, then builds a window from the body's intro
|
|
195
|
+
paragraph plus its first section's prose. The intro paragraph ends at the
|
|
196
|
+
earliest boundary among the first blank line and the first ATX header; when
|
|
197
|
+
neither boundary exists the whole body is the intro. The first section runs
|
|
198
|
+
from just after that first header to the next header (or end of body). The
|
|
199
|
+
intro and first section are joined with a blank line and returned with
|
|
200
|
+
Markdown ceremony stripped.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
body: The raw PR body markdown text.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The ceremony-stripped intro-paragraph plus first-section prose window
|
|
207
|
+
used for readability scoring.
|
|
208
|
+
"""
|
|
209
|
+
intro_paragraph = ""
|
|
210
|
+
body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
|
|
211
|
+
body_after_strip = body_without_fences.lstrip()
|
|
212
|
+
blank_line_position = body_after_strip.find("\n\n")
|
|
213
|
+
header_position_match = HEADING_LINE_PATTERN.search(body_after_strip)
|
|
214
|
+
header_position = header_position_match.start() if header_position_match else -1
|
|
215
|
+
|
|
216
|
+
if blank_line_position == -1 and header_position == -1:
|
|
217
|
+
intro_paragraph = body_after_strip
|
|
218
|
+
elif blank_line_position == -1:
|
|
219
|
+
intro_paragraph = body_after_strip[:header_position]
|
|
220
|
+
elif header_position == -1:
|
|
221
|
+
intro_paragraph = body_after_strip[:blank_line_position]
|
|
222
|
+
else:
|
|
223
|
+
first_boundary = min(blank_line_position, header_position)
|
|
224
|
+
intro_paragraph = body_after_strip[:first_boundary]
|
|
225
|
+
|
|
226
|
+
first_body_section = ""
|
|
227
|
+
if header_position_match is not None:
|
|
228
|
+
section_start = header_position_match.end()
|
|
229
|
+
remainder = body_after_strip[section_start:]
|
|
230
|
+
next_header_match = HEADING_LINE_PATTERN.search(remainder)
|
|
231
|
+
if next_header_match is not None:
|
|
232
|
+
first_body_section = remainder[: next_header_match.start()]
|
|
233
|
+
else:
|
|
234
|
+
first_body_section = remainder
|
|
235
|
+
|
|
236
|
+
combined_text = f"{intro_paragraph}\n\n{first_body_section}"
|
|
237
|
+
return strip_markdown_ceremony(combined_text)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _evaluate_readability_metrics(
|
|
241
|
+
target_text: str,
|
|
242
|
+
thresholds: ReadabilityThresholds,
|
|
243
|
+
) -> list[str]:
|
|
244
|
+
all_metric_violations: list[str] = []
|
|
245
|
+
all_sentences = _split_sentences(target_text)
|
|
246
|
+
if not all_sentences:
|
|
247
|
+
return all_metric_violations
|
|
248
|
+
word_counts_per_sentence: list[int] = []
|
|
249
|
+
for each_sentence in all_sentences:
|
|
250
|
+
sentence_words = [
|
|
251
|
+
each_token for each_token in re.split(r"\s+", each_sentence) if each_token
|
|
252
|
+
]
|
|
253
|
+
word_counts_per_sentence.append(len(sentence_words))
|
|
254
|
+
max_sentence_words = max(word_counts_per_sentence) if word_counts_per_sentence else 0
|
|
255
|
+
average_sentence_words = (
|
|
256
|
+
sum(word_counts_per_sentence) / len(word_counts_per_sentence)
|
|
257
|
+
if word_counts_per_sentence
|
|
258
|
+
else 0.0
|
|
259
|
+
)
|
|
260
|
+
if max_sentence_words > thresholds.max_sentence_words:
|
|
261
|
+
all_metric_violations.append(
|
|
262
|
+
f"Readability: longest sentence is {max_sentence_words} words "
|
|
263
|
+
f"(maximum {thresholds.max_sentence_words}); "
|
|
264
|
+
"split or rewrite the longest sentence"
|
|
265
|
+
)
|
|
266
|
+
if average_sentence_words > thresholds.avg_sentence_words:
|
|
267
|
+
all_metric_violations.append(
|
|
268
|
+
f"Readability: average sentence is {average_sentence_words:.1f} words "
|
|
269
|
+
f"(maximum {thresholds.avg_sentence_words}); "
|
|
270
|
+
"shorten or split your longest sentences"
|
|
271
|
+
)
|
|
272
|
+
flesch_score = _compute_flesch_reading_ease(target_text)
|
|
273
|
+
if flesch_score < thresholds.flesch_min:
|
|
274
|
+
all_metric_violations.append(
|
|
275
|
+
f"Readability: Flesch Reading Ease is {flesch_score:.1f} "
|
|
276
|
+
f"(minimum {thresholds.flesch_min}); use shorter words and sentences"
|
|
277
|
+
)
|
|
278
|
+
return all_metric_violations
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _build_readability_escape_hatch_message() -> str:
|
|
282
|
+
return (
|
|
283
|
+
"Readability strike threshold reached. Pick one: "
|
|
284
|
+
"(1) python <enforcer-path> --readability-loosen to widen thresholds 10%, "
|
|
285
|
+
"(2) python <enforcer-path> --readability-disable to skip the readability check, "
|
|
286
|
+
"(3) python <enforcer-path> --readability-reset to zero the strike counter, "
|
|
287
|
+
"(4) reply with the body plus the intended message to report a false positive."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _apply_readability_loosen() -> str:
|
|
292
|
+
current_thresholds = _load_readability_thresholds()
|
|
293
|
+
loosens_used = _read_loosens_used()
|
|
294
|
+
|
|
295
|
+
if loosens_used >= READABILITY_LOOSEN_CAP:
|
|
296
|
+
return "cap_reached"
|
|
297
|
+
|
|
298
|
+
if current_thresholds.flesch_min <= READABILITY_MIN_FLESCH_FLOOR:
|
|
299
|
+
return "floor_reached"
|
|
300
|
+
|
|
301
|
+
if current_thresholds.max_sentence_words >= READABILITY_MAX_SENTENCE_WORDS_CEILING:
|
|
302
|
+
return "ceiling_reached"
|
|
303
|
+
|
|
304
|
+
if current_thresholds.avg_sentence_words >= READABILITY_AVG_SENTENCE_WORDS_CEILING:
|
|
305
|
+
return "ceiling_reached"
|
|
306
|
+
|
|
307
|
+
next_flesch = max(
|
|
308
|
+
READABILITY_MIN_FLESCH_FLOOR,
|
|
309
|
+
math.floor(current_thresholds.flesch_min * READABILITY_FLESCH_LOOSEN_FACTOR),
|
|
310
|
+
)
|
|
311
|
+
next_max_sentence = min(
|
|
312
|
+
READABILITY_MAX_SENTENCE_WORDS_CEILING,
|
|
313
|
+
math.ceil(current_thresholds.max_sentence_words * READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR),
|
|
314
|
+
)
|
|
315
|
+
next_avg_sentence = min(
|
|
316
|
+
READABILITY_AVG_SENTENCE_WORDS_CEILING,
|
|
317
|
+
math.ceil(current_thresholds.avg_sentence_words * READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
next_payload: dict[str, object] = {
|
|
321
|
+
"flesch_min": next_flesch,
|
|
322
|
+
"max_sentence_words": next_max_sentence,
|
|
323
|
+
"avg_sentence_words": next_avg_sentence,
|
|
324
|
+
"loosens_used": loosens_used + 1,
|
|
325
|
+
}
|
|
326
|
+
_atomic_write_json(READABILITY_THRESHOLD_OVERRIDE_FILE, next_payload)
|
|
327
|
+
return "ok"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _apply_readability_reset() -> None:
|
|
331
|
+
_reset_strike_count()
|
|
332
|
+
_atomic_write_json(READABILITY_THRESHOLD_OVERRIDE_FILE, {"loosens_used": 0})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _dispatch_cli_flag(
|
|
336
|
+
flag_token: str,
|
|
337
|
+
output_stream: TextIO,
|
|
338
|
+
error_stream: TextIO,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Handle a single readability-management CLI flag and exit the process."""
|
|
341
|
+
if flag_token == "--readability-loosen":
|
|
342
|
+
outcome = _apply_readability_loosen()
|
|
343
|
+
if outcome == "cap_reached":
|
|
344
|
+
error_stream.write(
|
|
345
|
+
"loosen cap reached; use --readability-disable or --readability-reset\n"
|
|
346
|
+
)
|
|
347
|
+
sys.exit(1)
|
|
348
|
+
if outcome in {"floor_reached", "ceiling_reached"}:
|
|
349
|
+
error_stream.write(
|
|
350
|
+
"thresholds already at floor/ceiling; use --readability-disable or --readability-reset\n"
|
|
351
|
+
)
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
output_stream.write("readability thresholds loosened 10%\n")
|
|
354
|
+
sys.exit(0)
|
|
355
|
+
if flag_token == "--readability-reset":
|
|
356
|
+
_apply_readability_reset()
|
|
357
|
+
output_stream.write("readability strike counter and override thresholds reset\n")
|
|
358
|
+
sys.exit(0)
|
|
359
|
+
if flag_token == "--readability-disable":
|
|
360
|
+
_set_readability_enabled(False)
|
|
361
|
+
output_stream.write("readability check disabled\n")
|
|
362
|
+
sys.exit(0)
|
|
363
|
+
if flag_token == "--readability-enable":
|
|
364
|
+
_set_readability_enabled(True)
|
|
365
|
+
output_stream.write("readability check enabled\n")
|
|
366
|
+
sys.exit(0)
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"""Tests for ``check_function_length``.
|
|
2
2
|
|
|
3
|
-
Functions whose
|
|
4
|
-
inclusive
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Functions whose executable span (signature line through last body statement,
|
|
4
|
+
inclusive, minus the leading docstring lines of the function and of every
|
|
5
|
+
function or class nested within it) is at or above
|
|
6
|
+
``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` (60) block the write (small-function
|
|
7
|
+
basis: Robert C. Martin, Clean Code Ch. 3 "Functions"; Google Python Style
|
|
8
|
+
Guide ~40-line function review hint — a measure of executable complexity,
|
|
9
|
+
paired with the Guide's complete-docstring mandate for public APIs). Executable
|
|
10
|
+
spans below the threshold pass silently, whatever the docstring adds to the
|
|
11
|
+
full declared span; the issue message keeps reporting the full declared span so
|
|
12
|
+
the commit gate's span recovery holds.
|
|
8
13
|
|
|
9
14
|
Cited SYNTHESIS evidence: pa#143 F4, F9, F14 (three recurrences in one PR);
|
|
10
15
|
pa#136 F20.
|
|
@@ -208,3 +213,129 @@ def test_reports_only_in_scope_violation_among_untouched_ones() -> None:
|
|
|
208
213
|
)
|
|
209
214
|
assert any("target_function" in each_issue for each_issue in issues)
|
|
210
215
|
assert not any("leading_" in each_issue for each_issue in issues)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_docstring_function_source(
|
|
219
|
+
name: str, docstring_line_count: int, body_line_count: int
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Build a function whose leading docstring spans ``docstring_line_count + 2``
|
|
222
|
+
source lines (opening summary line, the counted filler lines, closing quotes)
|
|
223
|
+
followed by ``body_line_count`` executable statements."""
|
|
224
|
+
docstring_lines = [
|
|
225
|
+
' """Documented helper.',
|
|
226
|
+
*(
|
|
227
|
+
f" documentation line {each_index}."
|
|
228
|
+
for each_index in range(docstring_line_count)
|
|
229
|
+
),
|
|
230
|
+
' """',
|
|
231
|
+
]
|
|
232
|
+
all_source_lines = [
|
|
233
|
+
f"def {name}() -> None:",
|
|
234
|
+
*docstring_lines,
|
|
235
|
+
*(
|
|
236
|
+
f" statement_{each_index} = {each_index}"
|
|
237
|
+
for each_index in range(body_line_count)
|
|
238
|
+
),
|
|
239
|
+
]
|
|
240
|
+
return "\n".join(all_source_lines) + "\n"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_docstring_heavy_function_with_small_body_passes() -> None:
|
|
244
|
+
"""A complete Google-style docstring must not push a small-bodied function
|
|
245
|
+
over the gate: the threshold measures executable lines only."""
|
|
246
|
+
source = _build_docstring_function_source(
|
|
247
|
+
"documented_compact_helper",
|
|
248
|
+
docstring_line_count=hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
249
|
+
body_line_count=5,
|
|
250
|
+
)
|
|
251
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
252
|
+
assert issues == [], f"docstring lines must not count toward the gate, got: {issues!r}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_oversized_executable_body_blocks_despite_docstring() -> None:
|
|
256
|
+
"""A docstring does not acquit a genuinely large executable body, and the
|
|
257
|
+
issue message reports the full declared span so the commit gate's
|
|
258
|
+
``function_length_span_range`` recovery keeps covering the whole function."""
|
|
259
|
+
docstring_line_count = 10
|
|
260
|
+
body_line_count = hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
|
|
261
|
+
source = _build_docstring_function_source(
|
|
262
|
+
"documented_oversized_helper",
|
|
263
|
+
docstring_line_count=docstring_line_count,
|
|
264
|
+
body_line_count=body_line_count,
|
|
265
|
+
)
|
|
266
|
+
full_declared_span = 1 + (docstring_line_count + 2) + body_line_count
|
|
267
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
268
|
+
assert any("documented_oversized_helper" in each_issue for each_issue in issues)
|
|
269
|
+
assert any(f"is {full_declared_span} lines" in each_issue for each_issue in issues)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_executable_span_boundary_sits_one_below_threshold() -> None:
|
|
273
|
+
"""With the docstring excluded, an executable span of THRESHOLD - 1 passes
|
|
274
|
+
even though the full declared span sits far above the threshold."""
|
|
275
|
+
source = _build_docstring_function_source(
|
|
276
|
+
"documented_boundary_helper",
|
|
277
|
+
docstring_line_count=20,
|
|
278
|
+
body_line_count=hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 2,
|
|
279
|
+
)
|
|
280
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
281
|
+
assert issues == []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_builder_zero_docstring_line_count_keeps_span_contract() -> None:
|
|
285
|
+
"""The builder's docstring-span contract (``docstring_line_count + 2``) holds
|
|
286
|
+
at the zero boundary, so hand-computed span oracles in tests cannot drift."""
|
|
287
|
+
source = _build_docstring_function_source(
|
|
288
|
+
"documented_minimal_helper", docstring_line_count=0, body_line_count=3
|
|
289
|
+
)
|
|
290
|
+
expected_total_lines = 1 + (0 + 2) + 3
|
|
291
|
+
assert len(source.splitlines()) == expected_total_lines
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_builder_zero_body_line_count_keeps_span_contract() -> None:
|
|
295
|
+
"""The builder's span contract holds at the zero-body boundary, so a
|
|
296
|
+
docstring-only function's hand-computed span oracle cannot drift."""
|
|
297
|
+
source = _build_docstring_function_source(
|
|
298
|
+
"documented_bodyless_helper", docstring_line_count=5, body_line_count=0
|
|
299
|
+
)
|
|
300
|
+
expected_total_lines = 1 + (5 + 2) + 0
|
|
301
|
+
assert len(source.splitlines()) == expected_total_lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_nested_function_docstring_does_not_count_toward_outer() -> None:
|
|
305
|
+
"""A nested helper's docstring is documentation too: the outer function's
|
|
306
|
+
executable span excludes every leading docstring within its declared span."""
|
|
307
|
+
nested_docstring_filler = "\n".join(
|
|
308
|
+
f" nested documentation line {each_index}."
|
|
309
|
+
for each_index in range(hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD)
|
|
310
|
+
)
|
|
311
|
+
source = (
|
|
312
|
+
"def outer_documented_orchestrator() -> None:\n"
|
|
313
|
+
" def nested_documented_helper() -> None:\n"
|
|
314
|
+
' """Documented nested helper.\n'
|
|
315
|
+
f"{nested_docstring_filler}\n"
|
|
316
|
+
' """\n'
|
|
317
|
+
" nested_statement = 1\n"
|
|
318
|
+
" outer_statement = 2\n"
|
|
319
|
+
)
|
|
320
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
321
|
+
assert issues == [], f"nested docstring lines must not count toward the gate, got: {issues!r}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_nested_class_docstring_does_not_count_toward_outer() -> None:
|
|
325
|
+
"""A nested class's docstring is documentation too: the outer function's
|
|
326
|
+
executable span excludes leading docstrings of nested classes as well."""
|
|
327
|
+
nested_class_docstring_filler = "\n".join(
|
|
328
|
+
f" nested class documentation line {each_index}."
|
|
329
|
+
for each_index in range(hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD)
|
|
330
|
+
)
|
|
331
|
+
source = (
|
|
332
|
+
"def outer_class_documented_orchestrator() -> None:\n"
|
|
333
|
+
" class NestedDocumentedConfig:\n"
|
|
334
|
+
' """Documented nested class.\n'
|
|
335
|
+
f"{nested_class_docstring_filler}\n"
|
|
336
|
+
' """\n'
|
|
337
|
+
" nested_field = 1\n"
|
|
338
|
+
" outer_statement = 2\n"
|
|
339
|
+
)
|
|
340
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
341
|
+
assert issues == [], f"nested class docstring lines must not count toward the gate, got: {issues!r}"
|