claude-dev-env 1.50.0 → 1.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. package/hooks/blocking/test_md_to_html_blocker.py +0 -810
@@ -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