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.
- 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_annotations_length.py +167 -0
- package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
- package/hooks/blocking/code_rules_comments.py +337 -0
- package/hooks/blocking/code_rules_constants_config.py +252 -0
- package/hooks/blocking/code_rules_docstrings.py +308 -0
- package/hooks/blocking/code_rules_enforcer.py +98 -5765
- package/hooks/blocking/code_rules_imports_logging.py +276 -0
- package/hooks/blocking/code_rules_magic_values.py +180 -0
- package/hooks/blocking/code_rules_mock_completeness.py +295 -0
- package/hooks/blocking/code_rules_naming_collection.py +264 -0
- package/hooks/blocking/code_rules_optional_params.py +288 -0
- package/hooks/blocking/code_rules_paths_syspath.py +186 -0
- package/hooks/blocking/code_rules_probe_chains.py +305 -0
- package/hooks/blocking/code_rules_probe_detection.py +257 -0
- package/hooks/blocking/code_rules_probe_recording.py +225 -0
- package/hooks/blocking/code_rules_scope_binding.py +151 -0
- package/hooks/blocking/code_rules_shared.py +301 -0
- package/hooks/blocking/code_rules_string_magic.py +207 -0
- package/hooks/blocking/code_rules_test_assertions.py +226 -0
- package/hooks/blocking/code_rules_test_branching_except.py +181 -0
- package/hooks/blocking/code_rules_test_isolation.py +341 -0
- package/hooks/blocking/code_rules_type_escape.py +341 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
- package/hooks/blocking/code_rules_unused_imports.py +256 -0
- 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/tdd_enforcer.py +31 -0
- package/hooks/blocking/test_code_rules_constants_config.py +26 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
- package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
- package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
- package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
- package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
- package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
- package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
- package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
- 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/blocking/test_tdd_enforcer.py +116 -0
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
- 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)
|
|
@@ -208,7 +208,37 @@ def find_nearest_tests_directory(start_directory: Path) -> Path | None:
|
|
|
208
208
|
return None
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def _split_module_stem_prefix() -> str:
|
|
212
|
+
return "code_rules_"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _split_test_family_glob() -> str:
|
|
216
|
+
return "test_code_rules_enforcer_*.py"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
|
|
220
|
+
if not stem.startswith(_split_module_stem_prefix()):
|
|
221
|
+
return []
|
|
222
|
+
return sorted(directory.glob(_split_test_family_glob()))
|
|
223
|
+
|
|
224
|
+
|
|
211
225
|
def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
226
|
+
"""Return the test files whose freshness can satisfy the gate for a production file.
|
|
227
|
+
|
|
228
|
+
For ``code_rules_*`` Python modules the candidate list is extended with the
|
|
229
|
+
sibling split test family (``test_code_rules_enforcer_*.py``), because that
|
|
230
|
+
family collectively covers the split check modules; a fresh edit to any
|
|
231
|
+
family file satisfies the RED step for editing one of those modules. The
|
|
232
|
+
glob is directory-local, so ``code_rules_*`` files elsewhere gain no extra
|
|
233
|
+
candidates. Plain stem-derived candidates always come first.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
production_path: The production source file being written or edited.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Ordered candidate test paths; stem-derived siblings precede any
|
|
240
|
+
split-family additions.
|
|
241
|
+
"""
|
|
212
242
|
directory = production_path.parent
|
|
213
243
|
stem = production_path.stem
|
|
214
244
|
extension = production_path.suffix.lower()
|
|
@@ -220,6 +250,7 @@ def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
|
220
250
|
nearest_tests_directory = find_nearest_tests_directory(directory)
|
|
221
251
|
if nearest_tests_directory is not None:
|
|
222
252
|
all_candidates.append(nearest_tests_directory / f"test_{stem}.py")
|
|
253
|
+
all_candidates.extend(_split_family_candidates(directory, stem))
|
|
223
254
|
return all_candidates
|
|
224
255
|
|
|
225
256
|
if extension in {".tsx", ".ts", ".jsx", ".js"}:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Regression guard pinning docstring prose in code_rules_constants_config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
9
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
10
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
11
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
12
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
13
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
14
|
+
|
|
15
|
+
from code_rules_magic_values import check_magic_values # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_module_source_carries_no_docstring_magic_value() -> None:
|
|
19
|
+
module_path = Path(__file__).resolve().parent / "code_rules_constants_config.py"
|
|
20
|
+
module_source = module_path.read_text(encoding="utf-8")
|
|
21
|
+
magic_value_issues = check_magic_values(module_source, str(module_path))
|
|
22
|
+
assert magic_value_issues == [], (
|
|
23
|
+
"Docstring prose in code_rules_constants_config.py must not carry a "
|
|
24
|
+
"bare-number token that the magic-value check flags as a literal, "
|
|
25
|
+
f"got: {magic_value_issues}"
|
|
26
|
+
)
|
|
@@ -29,8 +29,11 @@ hook_module = importlib.util.module_from_spec(hook_spec)
|
|
|
29
29
|
hook_spec.loader.exec_module(hook_module)
|
|
30
30
|
check_banned_noun_word_boundary = hook_module.check_banned_noun_word_boundary
|
|
31
31
|
validate_content = hook_module.validate_content
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
|
|
33
|
+
from code_rules_banned_identifiers import ( # noqa: E402
|
|
34
|
+
_find_banned_noun_word,
|
|
35
|
+
_identifier_word_parts,
|
|
36
|
+
)
|
|
34
37
|
|
|
35
38
|
PRODUCTION_FILE_PATH = "packages/app/services/customer_pipeline.py"
|
|
36
39
|
TEST_FILE_PATH = "packages/app/services/test_customer_pipeline.py"
|
|
@@ -66,22 +66,17 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
|
|
|
66
66
|
"check_constant_equality_tests",
|
|
67
67
|
"check_constants_outside_config",
|
|
68
68
|
"check_constants_outside_config_advisory",
|
|
69
|
-
"check_e2e_test_naming",
|
|
70
69
|
"check_existence_check_tests",
|
|
71
70
|
"check_file_global_constants_use_count",
|
|
72
|
-
"check_fstring_structural_literals",
|
|
73
71
|
"check_imports_at_top",
|
|
74
72
|
"check_inline_literal_collections",
|
|
75
73
|
"check_library_print",
|
|
76
|
-
"check_logging_fstrings",
|
|
77
74
|
"check_loop_variable_naming",
|
|
78
|
-
"check_magic_values",
|
|
79
75
|
"check_parameter_annotations",
|
|
80
76
|
"check_return_annotations",
|
|
81
77
|
"check_skip_decorators_in_tests",
|
|
82
78
|
"check_string_literal_magic",
|
|
83
79
|
"check_unused_optional_parameters",
|
|
84
|
-
"check_windows_api_none",
|
|
85
80
|
}
|
|
86
81
|
)
|
|
87
82
|
|
|
@@ -13,22 +13,28 @@ a string still flag.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
-
import
|
|
16
|
+
import sys
|
|
17
17
|
from pathlib import Path
|
|
18
|
-
from types import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
from types import SimpleNamespace
|
|
19
|
+
|
|
20
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
21
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
22
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
23
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
24
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
25
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
26
|
+
|
|
27
|
+
from code_rules_comments import ( # noqa: E402
|
|
28
|
+
check_comment_changes,
|
|
29
|
+
check_comments_python,
|
|
30
|
+
extract_comment_texts,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
code_rules_enforcer = SimpleNamespace(
|
|
34
|
+
check_comment_changes=check_comment_changes,
|
|
35
|
+
check_comments_python=check_comments_python,
|
|
36
|
+
extract_comment_texts=extract_comment_texts,
|
|
37
|
+
)
|
|
32
38
|
|
|
33
39
|
|
|
34
40
|
def test_python_check_should_not_flag_hex_color_literal() -> None:
|