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,276 @@
|
|
|
1
|
+
"""Imports-at-top, logging f-string, win32gui None, E2E spec naming, file-length advisory, and library-print checks."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
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_path_utils import ( # noqa: E402
|
|
16
|
+
is_config_file,
|
|
17
|
+
)
|
|
18
|
+
from code_rules_shared import ( # noqa: E402
|
|
19
|
+
get_file_extension,
|
|
20
|
+
is_hook_infrastructure,
|
|
21
|
+
is_spec_file,
|
|
22
|
+
is_test_file,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
26
|
+
MAX_E2E_TEST_NAMING_ISSUES,
|
|
27
|
+
MAX_LOGGING_FSTRING_ISSUES,
|
|
28
|
+
MAX_WINDOWS_API_NONE_ISSUES,
|
|
29
|
+
)
|
|
30
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
31
|
+
ADVISORY_LINE_THRESHOLD_HARD,
|
|
32
|
+
ADVISORY_LINE_THRESHOLD_SOFT,
|
|
33
|
+
ALL_CLI_FILE_PATH_MARKERS,
|
|
34
|
+
ALL_IMPORT_STATEMENT_PREFIXES,
|
|
35
|
+
ALL_PYTHON_EXTENSIONS,
|
|
36
|
+
LOGGING_FSTRING_PATTERN,
|
|
37
|
+
NOT_INSIDE_TYPE_CHECKING_BLOCK,
|
|
38
|
+
TRIPLE_DOUBLE_QUOTE_DELIMITER,
|
|
39
|
+
TRIPLE_QUOTE_PARITY_DIVISOR,
|
|
40
|
+
TRIPLE_SINGLE_QUOTE_DELIMITER,
|
|
41
|
+
TYPE_CHECKING_BLOCK_PATTERN,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_imports_at_top(content: str) -> list[str]:
|
|
46
|
+
"""Check for imports inside functions (Python only).
|
|
47
|
+
|
|
48
|
+
An import lexically inside an ``if TYPE_CHECKING:`` block is exempt.
|
|
49
|
+
An import inside a function body is flagged even if the file uses TYPE_CHECKING
|
|
50
|
+
elsewhere at module scope.
|
|
51
|
+
|
|
52
|
+
Only the innermost ``if TYPE_CHECKING:`` block is tracked: a second, nested
|
|
53
|
+
``if TYPE_CHECKING:`` header overwrites the outer block's indent so that when
|
|
54
|
+
control dedents back to the outer block's body, the tracker resets.
|
|
55
|
+
|
|
56
|
+
Known limitation: nested ``if TYPE_CHECKING:`` blocks are NOT supported. After
|
|
57
|
+
a nested inner block ends, subsequent lines at the OUTER block's body indent
|
|
58
|
+
are treated as outside any TYPE_CHECKING scope, so function-body imports there
|
|
59
|
+
WILL be flagged as violations even though they are lexically guarded by the
|
|
60
|
+
outer block. Rewrite to a single top-level ``if TYPE_CHECKING:`` block to avoid
|
|
61
|
+
this false positive. Nested TYPE_CHECKING blocks are rare in practice, so this
|
|
62
|
+
simpler single-level tracking is preferred over maintaining a stack of indent
|
|
63
|
+
levels. The pinned behavior is covered by
|
|
64
|
+
``test_should_track_only_innermost_type_checking_block``.
|
|
65
|
+
|
|
66
|
+
Triple-quoted-string interior lines are skipped. Once a line opens a
|
|
67
|
+
multi-line triple-double-quote or triple-single-quote string (odd count
|
|
68
|
+
of the delimiter), every subsequent line is treated as docstring content
|
|
69
|
+
and exempt from the import-prefix scan until the matching delimiter
|
|
70
|
+
closes the string. Without this tracking, docstring sentences that
|
|
71
|
+
happen to start with ``from `` or ``import `` after stripping (a common
|
|
72
|
+
pattern in narrative docstrings) would fire a false positive.
|
|
73
|
+
"""
|
|
74
|
+
issues: list[str] = []
|
|
75
|
+
lines = content.split("\n")
|
|
76
|
+
is_inside_function = False
|
|
77
|
+
function_indent = 0
|
|
78
|
+
type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
79
|
+
active_triple_quote_delimiter: str | None = None
|
|
80
|
+
|
|
81
|
+
for each_line_number, each_line in enumerate(lines, 1):
|
|
82
|
+
if active_triple_quote_delimiter is not None:
|
|
83
|
+
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
84
|
+
each_line, active_triple_quote_delimiter
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
stripped = each_line.strip()
|
|
89
|
+
|
|
90
|
+
if not stripped:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
current_indent = len(each_line) - len(each_line.lstrip())
|
|
94
|
+
|
|
95
|
+
if type_checking_block_indent != NOT_INSIDE_TYPE_CHECKING_BLOCK:
|
|
96
|
+
if current_indent <= type_checking_block_indent:
|
|
97
|
+
type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
98
|
+
|
|
99
|
+
type_checking_match = TYPE_CHECKING_BLOCK_PATTERN.match(each_line)
|
|
100
|
+
if type_checking_match:
|
|
101
|
+
type_checking_block_indent = len(type_checking_match.group("indent"))
|
|
102
|
+
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
103
|
+
each_line, active_triple_quote_delimiter
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
function_match = re.match(r"^(\s*)(async\s+)?def\s+\w+", each_line)
|
|
108
|
+
if function_match:
|
|
109
|
+
is_inside_function = True
|
|
110
|
+
function_indent = len(function_match.group(1)) if function_match.group(1) else 0
|
|
111
|
+
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
112
|
+
each_line, active_triple_quote_delimiter
|
|
113
|
+
)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if is_inside_function:
|
|
117
|
+
if current_indent <= function_indent and stripped and not stripped.startswith(("#", "@", ")")):
|
|
118
|
+
is_inside_function = False
|
|
119
|
+
|
|
120
|
+
is_inside_type_checking_block = type_checking_block_indent != NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
121
|
+
if is_inside_function and not is_inside_type_checking_block:
|
|
122
|
+
if stripped.startswith(ALL_IMPORT_STATEMENT_PREFIXES):
|
|
123
|
+
issues.append(f"Line {each_line_number}: Import inside function - move to top of file")
|
|
124
|
+
|
|
125
|
+
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
126
|
+
each_line, active_triple_quote_delimiter
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return issues
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _update_triple_quote_state_for_line(
|
|
133
|
+
line_text: str, current_delimiter: str | None
|
|
134
|
+
) -> str | None:
|
|
135
|
+
"""Return the triple-quote delimiter that remains active after the line.
|
|
136
|
+
|
|
137
|
+
Naively counts triple-double-quote and triple-single-quote occurrences.
|
|
138
|
+
An odd count of either delimiter toggles the active state: ``None``
|
|
139
|
+
becomes that delimiter, the same delimiter becomes ``None``. Even counts
|
|
140
|
+
mean the line opens and closes the same delimiter in place (single-line
|
|
141
|
+
docstring or balanced pair) and the active state is unchanged.
|
|
142
|
+
|
|
143
|
+
Known limitation: the counter does not distinguish triple quotes that
|
|
144
|
+
appear inside other string contexts (for example, a raw f-string
|
|
145
|
+
containing the literal substring of triple quotes). Such constructs are
|
|
146
|
+
rare in docstring-bearing code; the false-negative risk is acceptable
|
|
147
|
+
to keep the line-walker simple and dependency-free.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
line_text: The raw source line whose triple-quote balance is being
|
|
151
|
+
integrated into the running state.
|
|
152
|
+
current_delimiter: The active delimiter at the start of this line,
|
|
153
|
+
or ``None`` when no multi-line string is open.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The delimiter that remains active after this line, or ``None`` when
|
|
157
|
+
no string is open.
|
|
158
|
+
"""
|
|
159
|
+
if current_delimiter is not None:
|
|
160
|
+
if line_text.count(current_delimiter) % TRIPLE_QUOTE_PARITY_DIVISOR == 1:
|
|
161
|
+
return None
|
|
162
|
+
return current_delimiter
|
|
163
|
+
if line_text.count(TRIPLE_DOUBLE_QUOTE_DELIMITER) % TRIPLE_QUOTE_PARITY_DIVISOR == 1:
|
|
164
|
+
return TRIPLE_DOUBLE_QUOTE_DELIMITER
|
|
165
|
+
if line_text.count(TRIPLE_SINGLE_QUOTE_DELIMITER) % TRIPLE_QUOTE_PARITY_DIVISOR == 1:
|
|
166
|
+
return TRIPLE_SINGLE_QUOTE_DELIMITER
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def check_logging_fstrings(content: str) -> list[str]:
|
|
171
|
+
"""Check for f-strings in logging calls."""
|
|
172
|
+
issues = []
|
|
173
|
+
pattern = LOGGING_FSTRING_PATTERN
|
|
174
|
+
|
|
175
|
+
maximum_issues = MAX_LOGGING_FSTRING_ISSUES
|
|
176
|
+
for each_line_number, each_line in enumerate(content.split("\n"), 1):
|
|
177
|
+
if pattern.search(each_line):
|
|
178
|
+
issues.append(f"Line {each_line_number}: f-string in log call - use format args instead")
|
|
179
|
+
|
|
180
|
+
if len(issues) >= maximum_issues:
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
return issues
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def advise_file_line_count(content: str, file_path: str) -> None:
|
|
187
|
+
"""Emit non-blocking stderr advisories when a file crosses size smell thresholds.
|
|
188
|
+
|
|
189
|
+
Thresholds are smell signals, not hard caps. See CODE_RULES.md "File length guidance"
|
|
190
|
+
for rationale. Soft threshold aligns with Clean Code Chapter Five / Fowler "Large Class".
|
|
191
|
+
Hard threshold matches pylint default max-module-lines and SonarQube S104 default.
|
|
192
|
+
"""
|
|
193
|
+
line_count = len(content.splitlines())
|
|
194
|
+
if line_count >= ADVISORY_LINE_THRESHOLD_HARD:
|
|
195
|
+
print(
|
|
196
|
+
f"[CODE_RULES advisory] {file_path}: {line_count} lines - "
|
|
197
|
+
f"exceeds pylint/SonarQube default ({ADVISORY_LINE_THRESHOLD_HARD}); "
|
|
198
|
+
f"strongly consider splitting by responsibility (SRP / cohesion)",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
elif line_count >= ADVISORY_LINE_THRESHOLD_SOFT:
|
|
202
|
+
print(
|
|
203
|
+
f"[CODE_RULES advisory] {file_path}: {line_count} lines - "
|
|
204
|
+
f"consider splitting (Clean Code Ch. 5; Fowler 'Large Class' smell)",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_windows_api_none(content: str) -> list[str]:
|
|
210
|
+
"""Check for win32gui calls with None parameter."""
|
|
211
|
+
issues = []
|
|
212
|
+
pattern = re.compile(r"win32gui\.\w+\s*\([^)]*,\s*None\s*\)")
|
|
213
|
+
|
|
214
|
+
maximum_issues = MAX_WINDOWS_API_NONE_ISSUES
|
|
215
|
+
for each_line_number, each_line in enumerate(content.split("\n"), 1):
|
|
216
|
+
if pattern.search(each_line):
|
|
217
|
+
issues.append(f"Line {each_line_number}: win32gui call with None - use 0 for unused int params")
|
|
218
|
+
|
|
219
|
+
if len(issues) >= maximum_issues:
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
return issues
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
|
|
226
|
+
"""Check for online/offline in test names (spec files only)."""
|
|
227
|
+
if not is_spec_file(file_path):
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
issues = []
|
|
231
|
+
pattern = re.compile(r'(test|it|describe)\s*\(\s*["\'][^"\']*\b(online|offline)\b[^"\']*["\']', re.IGNORECASE)
|
|
232
|
+
|
|
233
|
+
maximum_issues = MAX_E2E_TEST_NAMING_ISSUES
|
|
234
|
+
for each_line_number, each_line in enumerate(content.split("\n"), 1):
|
|
235
|
+
if pattern.search(each_line):
|
|
236
|
+
issues.append(f"Line {each_line_number}: Test name contains online/offline - file scope defines this")
|
|
237
|
+
|
|
238
|
+
if len(issues) >= maximum_issues:
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
return issues
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_cli_entry_point(file_path: str) -> bool:
|
|
245
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
246
|
+
return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def check_library_print(content: str, file_path: str) -> list[str]:
|
|
250
|
+
if is_test_file(file_path) or is_config_file(file_path) or is_hook_infrastructure(file_path):
|
|
251
|
+
return []
|
|
252
|
+
if _is_cli_entry_point(file_path):
|
|
253
|
+
return []
|
|
254
|
+
if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
|
|
255
|
+
return []
|
|
256
|
+
try:
|
|
257
|
+
tree = ast.parse(content)
|
|
258
|
+
except SyntaxError:
|
|
259
|
+
return []
|
|
260
|
+
issues: list[str] = []
|
|
261
|
+
for each_node in ast.walk(tree):
|
|
262
|
+
if not isinstance(each_node, ast.Call):
|
|
263
|
+
continue
|
|
264
|
+
function_reference = each_node.func
|
|
265
|
+
if isinstance(function_reference, ast.Name) and function_reference.id == "print":
|
|
266
|
+
issues.append(
|
|
267
|
+
f"Line {each_node.lineno}: Library print() - route through logger or accept an explicit stream parameter"
|
|
268
|
+
)
|
|
269
|
+
elif isinstance(function_reference, ast.Attribute) and function_reference.attr == "write":
|
|
270
|
+
value_node = function_reference.value
|
|
271
|
+
if isinstance(value_node, ast.Attribute) and isinstance(value_node.value, ast.Name):
|
|
272
|
+
if value_node.value.id == "sys" and value_node.attr in {"stdout", "stderr"}:
|
|
273
|
+
issues.append(
|
|
274
|
+
f"Line {each_node.lineno}: sys.{value_node.attr}.write - route through logger"
|
|
275
|
+
)
|
|
276
|
+
return issues
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Magic-number and f-string structural-literal checks for function bodies."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
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_path_utils import ( # noqa: E402
|
|
16
|
+
is_config_file,
|
|
17
|
+
)
|
|
18
|
+
from code_rules_shared import ( # noqa: E402
|
|
19
|
+
_extract_fstring_literal_parts,
|
|
20
|
+
is_test_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
24
|
+
ALL_ALLOWED_MAGIC_NUMBER_LITERALS,
|
|
25
|
+
ALL_NON_MAGIC_FSTRING_STRIPPED_VALUES,
|
|
26
|
+
MAX_FSTRING_STRUCTURAL_LITERAL_ISSUES,
|
|
27
|
+
MAX_MAGIC_VALUE_ISSUES,
|
|
28
|
+
MINIMUM_FSTRING_LITERAL_LENGTH,
|
|
29
|
+
STRING_LITERAL_QUOTE_PAIR_LENGTH,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mask_string_literals_preserving_length(source_line: str) -> str:
|
|
34
|
+
"""Replace every string literal with an equal-length neutral placeholder.
|
|
35
|
+
|
|
36
|
+
Matching tests live in
|
|
37
|
+
``test_code_rules_enforcer_magic_string_masking.py``, one of the
|
|
38
|
+
``test_code_rules_enforcer_<suffix>.py`` family files the
|
|
39
|
+
``tdd_enforcer.py`` hook accepts as test candidates for the
|
|
40
|
+
``code_rules_*`` module family.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
string_literal_pattern = re.compile(
|
|
44
|
+
r"(\"(?:\\.|[^\"\\])*\")|('(?:\\.|[^'\\])*')",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _replace_string_literal(match: re.Match[str]) -> str:
|
|
48
|
+
matched_literal = match.group(0)
|
|
49
|
+
opening_quote = matched_literal[0]
|
|
50
|
+
closing_quote = matched_literal[-1]
|
|
51
|
+
inner_length = max(len(matched_literal) - STRING_LITERAL_QUOTE_PAIR_LENGTH, 0)
|
|
52
|
+
return f"{opening_quote}{'_' * inner_length}{closing_quote}"
|
|
53
|
+
|
|
54
|
+
return string_literal_pattern.sub(_replace_string_literal, source_line)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
58
|
+
"""Check for magic values in function bodies."""
|
|
59
|
+
if is_config_file(file_path) or is_test_file(file_path):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
issues = []
|
|
63
|
+
lines = content.split("\n")
|
|
64
|
+
is_inside_function = False
|
|
65
|
+
|
|
66
|
+
number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
|
|
67
|
+
allowed_numbers = ALL_ALLOWED_MAGIC_NUMBER_LITERALS
|
|
68
|
+
|
|
69
|
+
for each_line_number, each_line in enumerate(lines, 1):
|
|
70
|
+
stripped = each_line.strip()
|
|
71
|
+
|
|
72
|
+
if not stripped:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if re.match(r"^(async\s+)?def\s+\w+", stripped):
|
|
76
|
+
is_inside_function = True
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if re.match(r"^class\s+\w+", stripped):
|
|
80
|
+
is_inside_function = False
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if is_inside_function:
|
|
84
|
+
if "=" in stripped and stripped.split("=")[0].strip().isupper():
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if stripped.startswith(("return", "yield", "raise")):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
stripped_without_string_literals = _mask_string_literals_preserving_length(stripped)
|
|
91
|
+
numbers_found = number_pattern.findall(stripped_without_string_literals)
|
|
92
|
+
for each_number in numbers_found:
|
|
93
|
+
if each_number not in allowed_numbers:
|
|
94
|
+
if "range(" in stripped_without_string_literals or "enumerate(" in stripped_without_string_literals:
|
|
95
|
+
continue
|
|
96
|
+
if "[" in stripped_without_string_literals and "]" in stripped_without_string_literals:
|
|
97
|
+
continue
|
|
98
|
+
issues.append(f"Line {each_line_number}: Magic value {each_number} - extract to named constant")
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if len(issues) >= MAX_MAGIC_VALUE_ISSUES:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
return issues
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _has_structural_shape(literal_body: str) -> bool:
|
|
108
|
+
"""Return True when a literal body looks like a path, URL, or regex.
|
|
109
|
+
|
|
110
|
+
Natural English containing a single slash (e.g. ``online/offline``,
|
|
111
|
+
``CI/CD``, ``and/or``) must NOT match. Only multi-segment paths,
|
|
112
|
+
URL schemes, Windows drive prefixes, leading absolute paths, regex
|
|
113
|
+
escape sequences (``\\d``, ``\\w``, ``\\s`` and friends), or regex
|
|
114
|
+
anchors at the boundary are treated as structural.
|
|
115
|
+
"""
|
|
116
|
+
if re.search(r"\w+/\w+/\w+", literal_body):
|
|
117
|
+
return True
|
|
118
|
+
if re.search(r"\w+\\\w+\\\w+", literal_body):
|
|
119
|
+
return True
|
|
120
|
+
if re.search(r"[A-Za-z][A-Za-z0-9+.\-]*://", literal_body):
|
|
121
|
+
return True
|
|
122
|
+
if re.search(r"(^|\s)[A-Za-z]:[\\/]", literal_body):
|
|
123
|
+
return True
|
|
124
|
+
if re.search(r"^/\w+/\w+", literal_body):
|
|
125
|
+
return True
|
|
126
|
+
if re.search(r"\\[dwsDWSbBAZ]|\\\d", literal_body):
|
|
127
|
+
return True
|
|
128
|
+
if literal_body.startswith("^") or literal_body.endswith("$"):
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_fstring_structural_literals(content: str, file_path: str) -> list[str]:
|
|
134
|
+
"""Flag f-strings whose literal fragments look like paths, URLs, or regex.
|
|
135
|
+
|
|
136
|
+
Parses the file with :mod:`ast` so every f-string form is handled
|
|
137
|
+
uniformly: single, triple-quoted, raw (``rf`` / ``fr``), and strings
|
|
138
|
+
containing apostrophes or escaped braces. The literal portions of
|
|
139
|
+
each ``JoinedStr`` node are concatenated, and the result is treated
|
|
140
|
+
as a structural magic value only when :func:`_has_structural_shape`
|
|
141
|
+
matches a multi-segment path, a URL scheme, a Windows drive prefix,
|
|
142
|
+
a leading absolute path, a regex escape sequence, or a boundary
|
|
143
|
+
regex anchor.
|
|
144
|
+
|
|
145
|
+
The enforcer hook file, config files, and test files are all exempt.
|
|
146
|
+
Syntax errors in the input silently produce no issues, matching the
|
|
147
|
+
behaviour of the other lint-style checks in this module.
|
|
148
|
+
"""
|
|
149
|
+
if is_config_file(file_path) or is_test_file(file_path):
|
|
150
|
+
return []
|
|
151
|
+
if file_path.replace("\\", "/").endswith("hooks/blocking/code_rules_enforcer.py"):
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
syntax_tree = ast.parse(content)
|
|
156
|
+
except SyntaxError:
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
minimum_literal_length = MINIMUM_FSTRING_LITERAL_LENGTH
|
|
160
|
+
maximum_issues_before_stop = MAX_FSTRING_STRUCTURAL_LITERAL_ISSUES
|
|
161
|
+
non_magic_stripped_values = ALL_NON_MAGIC_FSTRING_STRIPPED_VALUES
|
|
162
|
+
|
|
163
|
+
issues: list[str] = []
|
|
164
|
+
for each_node in ast.walk(syntax_tree):
|
|
165
|
+
if not isinstance(each_node, ast.JoinedStr):
|
|
166
|
+
continue
|
|
167
|
+
display_body, shape_body = _extract_fstring_literal_parts(each_node)
|
|
168
|
+
if display_body in non_magic_stripped_values:
|
|
169
|
+
continue
|
|
170
|
+
if len(display_body) < minimum_literal_length:
|
|
171
|
+
continue
|
|
172
|
+
if not _has_structural_shape(shape_body):
|
|
173
|
+
continue
|
|
174
|
+
issues.append(
|
|
175
|
+
f"Line {each_node.lineno}: Structural literal inside f-string {display_body!r} - extract to config"
|
|
176
|
+
)
|
|
177
|
+
if len(issues) >= maximum_issues_before_stop:
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
return issues
|