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,207 @@
1
+ """Bare string-literal magic, inline literal-collection, and inline tuple string-magic 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
+ _walk_skipping_nested_function_defs,
20
+ is_migration_file,
21
+ is_test_file,
22
+ is_workflow_registry_file,
23
+ )
24
+
25
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
26
+ ALL_CAPS_WITH_UNDERSCORE_PATTERN,
27
+ DOTTED_SEGMENT_PATTERN,
28
+ INLINE_COLLECTION_MIN_LENGTH,
29
+ )
30
+ from hooks_constants.inline_tuple_string_magic_constants import ( # noqa: E402
31
+ ALL_SNAKE_CASE_KEYWORD_EXEMPTIONS,
32
+ EXPECTED_TUPLE_PAIR_LENGTH,
33
+ INLINE_TUPLE_STRING_MAGIC_MESSAGE_SUFFIX,
34
+ MAX_INLINE_TUPLE_STRING_MAGIC_ISSUES,
35
+ SNAKE_CASE_LITERAL_PATTERN,
36
+ )
37
+
38
+
39
+ def _is_magic_string_literal(string_value: str) -> bool:
40
+ if not string_value:
41
+ return False
42
+ if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(string_value):
43
+ return True
44
+ if DOTTED_SEGMENT_PATTERN.match(string_value):
45
+ return True
46
+ return False
47
+
48
+
49
+ def _collect_docstring_node_ids(tree: ast.Module) -> set[int]:
50
+ docstring_ids: set[int] = set()
51
+ docstring_owner_node_types = (
52
+ ast.Module,
53
+ ast.FunctionDef,
54
+ ast.AsyncFunctionDef,
55
+ ast.ClassDef,
56
+ )
57
+ for each_node in ast.walk(tree):
58
+ if not isinstance(each_node, docstring_owner_node_types):
59
+ continue
60
+ if not each_node.body:
61
+ continue
62
+ first_statement = each_node.body[0]
63
+ if not isinstance(first_statement, ast.Expr):
64
+ continue
65
+ first_value = first_statement.value
66
+ if isinstance(first_value, ast.Constant) and isinstance(first_value.value, str):
67
+ docstring_ids.add(id(first_value))
68
+ return docstring_ids
69
+
70
+
71
+ def _collect_fstring_part_node_ids(tree: ast.Module) -> set[int]:
72
+ fstring_part_ids: set[int] = set()
73
+ for each_node in ast.walk(tree):
74
+ if not isinstance(each_node, ast.JoinedStr):
75
+ continue
76
+ for each_value in each_node.values:
77
+ if isinstance(each_value, ast.Constant) and isinstance(each_value.value, str):
78
+ fstring_part_ids.add(id(each_value))
79
+ return fstring_part_ids
80
+
81
+
82
+ def check_string_literal_magic(content: str, file_path: str) -> list[str]:
83
+ if is_test_file(file_path):
84
+ return []
85
+ if is_config_file(file_path):
86
+ return []
87
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
88
+ return []
89
+ try:
90
+ tree = ast.parse(content)
91
+ except SyntaxError:
92
+ return []
93
+ docstring_node_ids = _collect_docstring_node_ids(tree)
94
+ fstring_part_node_ids = _collect_fstring_part_node_ids(tree)
95
+ issues: list[str] = []
96
+ flagged_node_ids: set[int] = set()
97
+ for each_function_node in ast.walk(tree):
98
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
99
+ continue
100
+ for each_body_statement in each_function_node.body:
101
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
102
+ if not isinstance(each_descendant, ast.Constant):
103
+ continue
104
+ if not isinstance(each_descendant.value, str):
105
+ continue
106
+ if id(each_descendant) in flagged_node_ids:
107
+ continue
108
+ if id(each_descendant) in docstring_node_ids:
109
+ continue
110
+ if id(each_descendant) in fstring_part_node_ids:
111
+ continue
112
+ if not _is_magic_string_literal(each_descendant.value):
113
+ continue
114
+ flagged_node_ids.add(id(each_descendant))
115
+ issues.append(
116
+ f"Line {each_descendant.lineno}: string magic value {each_descendant.value!r}"
117
+ f" - extract to config/"
118
+ )
119
+ return issues
120
+
121
+
122
+ def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
123
+ if is_test_file(file_path):
124
+ return []
125
+ if is_config_file(file_path):
126
+ return []
127
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
128
+ return []
129
+ try:
130
+ tree = ast.parse(content)
131
+ except SyntaxError:
132
+ return []
133
+ issues: list[str] = []
134
+ flagged_node_ids: set[int] = set()
135
+ for each_function_node in ast.walk(tree):
136
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
137
+ continue
138
+ for each_body_statement in each_function_node.body:
139
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
140
+ if not isinstance(each_descendant, (ast.Set, ast.List)):
141
+ continue
142
+ if id(each_descendant) in flagged_node_ids:
143
+ continue
144
+ all_elements = each_descendant.elts
145
+ if len(all_elements) < INLINE_COLLECTION_MIN_LENGTH:
146
+ continue
147
+ if not all(isinstance(each_element, ast.Constant) for each_element in all_elements):
148
+ continue
149
+ flagged_node_ids.add(id(each_descendant))
150
+ collection_kind = "set" if isinstance(each_descendant, ast.Set) else "list"
151
+ issues.append(
152
+ f"Line {each_descendant.lineno}: inline {collection_kind} literal of {len(all_elements)}"
153
+ f" constants in function body - extract to config/"
154
+ )
155
+ return issues
156
+
157
+
158
+ def check_inline_tuple_string_magic(content: str, file_path: str) -> list[str]:
159
+ """Flag inline two-tuple literals whose first element is a snake_case string.
160
+
161
+ Catches the pattern ``("kept", "Unknown status")`` and similar
162
+ column-name/key-value pairs declared inside function bodies. Files under
163
+ ``config/`` and test files are exempt because that is where named
164
+ constants are expected to live.
165
+ """
166
+ if is_test_file(file_path):
167
+ return []
168
+ if is_config_file(file_path):
169
+ return []
170
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
171
+ return []
172
+ try:
173
+ tree = ast.parse(content)
174
+ except SyntaxError:
175
+ return []
176
+ snake_case_pattern = re.compile(SNAKE_CASE_LITERAL_PATTERN)
177
+ issues: list[str] = []
178
+ seen_tuple_node_ids: set[int] = set()
179
+ for each_function_node in ast.walk(tree):
180
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
181
+ continue
182
+ for each_body_statement in each_function_node.body:
183
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
184
+ if not isinstance(each_descendant, ast.Tuple):
185
+ continue
186
+ if id(each_descendant) in seen_tuple_node_ids:
187
+ continue
188
+ seen_tuple_node_ids.add(id(each_descendant))
189
+ if len(each_descendant.elts) != EXPECTED_TUPLE_PAIR_LENGTH:
190
+ continue
191
+ first_element = each_descendant.elts[0]
192
+ if not isinstance(first_element, ast.Constant):
193
+ continue
194
+ if not isinstance(first_element.value, str):
195
+ continue
196
+ literal_text = first_element.value
197
+ if not snake_case_pattern.match(literal_text):
198
+ continue
199
+ if literal_text in ALL_SNAKE_CASE_KEYWORD_EXEMPTIONS:
200
+ continue
201
+ issues.append(
202
+ f"Line {first_element.lineno}: Column-name string magic "
203
+ f"{literal_text!r} - {INLINE_TUPLE_STRING_MAGIC_MESSAGE_SUFFIX}"
204
+ )
205
+ if len(issues) >= MAX_INLINE_TUPLE_STRING_MAGIC_ISSUES:
206
+ return issues
207
+ return issues
@@ -0,0 +1,226 @@
1
+ """Skip-decorator, existence-only, and constant-equality test-quality checks."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
8
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
9
+ if _BLOCKING_DIRECTORY not in sys.path:
10
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
11
+ if _HOOKS_DIRECTORY not in sys.path:
12
+ sys.path.insert(0, _HOOKS_DIRECTORY)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ is_test_file,
16
+ )
17
+
18
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
19
+ UPPER_SNAKE_CONSTANT_PATTERN,
20
+ )
21
+
22
+
23
+ def _decorator_name_contains_skip(decorator_node: ast.expr) -> bool:
24
+ """Return True when a decorator AST node references an identifier containing 'skip'."""
25
+ if isinstance(decorator_node, ast.Name):
26
+ return "skip" in decorator_node.id.lower()
27
+ if isinstance(decorator_node, ast.Attribute):
28
+ return "skip" in decorator_node.attr.lower()
29
+ if isinstance(decorator_node, ast.Call):
30
+ return _decorator_name_contains_skip(decorator_node.func)
31
+ return False
32
+
33
+
34
+ def check_skip_decorators_in_tests(content: str, file_path: str) -> list[str]:
35
+ """Flag @skip decorators on test functions in test files.
36
+
37
+ Tests must fail on missing dependencies rather than skip silently.
38
+ Only applies to test files; production files are exempt.
39
+ Only flags decorators applied to functions whose names start with 'test'.
40
+ """
41
+ if not is_test_file(file_path):
42
+ return []
43
+
44
+ try:
45
+ syntax_tree = ast.parse(content)
46
+ except SyntaxError:
47
+ return []
48
+
49
+ issues: list[str] = []
50
+ for each_node in ast.walk(syntax_tree):
51
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
52
+ continue
53
+ if not each_node.name.startswith("test"):
54
+ continue
55
+ for each_decorator in each_node.decorator_list:
56
+ if _decorator_name_contains_skip(each_decorator):
57
+ issues.append(
58
+ f"Line {each_decorator.lineno}: @skip decorator on test"
59
+ f" — tests must fail on missing deps"
60
+ )
61
+
62
+ return issues
63
+
64
+
65
+ def _collect_assert_nodes_bounded(node: ast.AST) -> list[ast.Assert]:
66
+ """Collect Assert nodes under node without crossing scope boundaries.
67
+
68
+ Terminates descent at FunctionDef, AsyncFunctionDef, ClassDef, and Lambda
69
+ nodes so that assertions belonging to nested scopes are not attributed to
70
+ the enclosing function body.
71
+ """
72
+ scope_boundary_types = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
73
+ assertions: list[ast.Assert] = []
74
+ nodes_to_visit: list[ast.AST] = list(ast.iter_child_nodes(node))
75
+ while nodes_to_visit:
76
+ current = nodes_to_visit.pop()
77
+ if isinstance(current, ast.Assert):
78
+ assertions.append(current)
79
+ if isinstance(current, scope_boundary_types):
80
+ continue
81
+ nodes_to_visit.extend(ast.iter_child_nodes(current))
82
+ return assertions
83
+
84
+
85
+ def _collect_body_assertions(statement_nodes: list[ast.stmt]) -> list[ast.Assert]:
86
+ """Collect Assert nodes from a function body without descending into nested scopes."""
87
+ assertions: list[ast.Assert] = []
88
+ for each_stmt in statement_nodes:
89
+ if isinstance(each_stmt, ast.Assert):
90
+ assertions.append(each_stmt)
91
+ continue
92
+ if isinstance(each_stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
93
+ continue
94
+ assertions.extend(_collect_assert_nodes_bounded(each_stmt))
95
+ return assertions
96
+
97
+
98
+ def _is_existence_only_assertion(call_node: ast.Call) -> bool:
99
+ """Return True when a Call node is callable() or hasattr()."""
100
+ function_reference = call_node.func
101
+ if isinstance(function_reference, ast.Name):
102
+ return function_reference.id in ("callable", "hasattr")
103
+ if isinstance(function_reference, ast.Attribute):
104
+ return function_reference.attr in ("callable", "hasattr")
105
+ return False
106
+
107
+
108
+ def _test_body_has_only_existence_assertions(
109
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
110
+ ) -> bool:
111
+ """Return True when a test function body contains only existence-check assertions."""
112
+ assertion_nodes = _collect_body_assertions(function_node.body)
113
+ if not assertion_nodes:
114
+ return False
115
+
116
+ non_existence_assertions = 0
117
+ for each_assert in assertion_nodes:
118
+ test_expr = each_assert.test
119
+ if isinstance(test_expr, ast.Call) and _is_existence_only_assertion(test_expr):
120
+ continue
121
+ if isinstance(test_expr, ast.Compare):
122
+ comparators = test_expr.comparators
123
+ ops = test_expr.ops
124
+ if (
125
+ len(ops) == 1
126
+ and isinstance(ops[0], ast.IsNot)
127
+ and len(comparators) == 1
128
+ and isinstance(comparators[0], ast.Constant)
129
+ and comparators[0].value is None
130
+ ):
131
+ continue
132
+ non_existence_assertions += 1
133
+
134
+ return non_existence_assertions == 0
135
+
136
+
137
+ def check_existence_check_tests(content: str, file_path: str) -> list[str]:
138
+ """Flag test functions containing only existence-check assertions.
139
+
140
+ Tests asserting only callable(x), hasattr(m, 'name'), or x is not None
141
+ verify nothing about behavior. They should be deleted or replaced with
142
+ assertions that exercise actual functionality.
143
+ Only applies to test files.
144
+ """
145
+ if not is_test_file(file_path):
146
+ return []
147
+
148
+ try:
149
+ syntax_tree = ast.parse(content)
150
+ except SyntaxError:
151
+ return []
152
+
153
+ issues: list[str] = []
154
+ for each_node in ast.walk(syntax_tree):
155
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
156
+ continue
157
+ if not each_node.name.startswith("test"):
158
+ continue
159
+ if _test_body_has_only_existence_assertions(each_node):
160
+ issues.append(
161
+ f"Line {each_node.lineno}: existence-check test"
162
+ f" — delete or replace with a behavior test"
163
+ )
164
+
165
+ return issues
166
+
167
+
168
+ def _is_upper_snake_name(name: str) -> bool:
169
+ """Return True when an identifier is written in UPPER_SNAKE_CASE."""
170
+ return bool(UPPER_SNAKE_CONSTANT_PATTERN.match(name))
171
+
172
+
173
+ def _assert_is_constant_equality_only(assert_node: ast.Assert) -> bool:
174
+ """Return True when the assertion compares an UPPER_SNAKE name to a literal."""
175
+ test_expr = assert_node.test
176
+ if not isinstance(test_expr, ast.Compare):
177
+ return False
178
+ if len(test_expr.ops) != 1 or not isinstance(test_expr.ops[0], ast.Eq):
179
+ return False
180
+ left = test_expr.left
181
+ right = test_expr.comparators[0]
182
+ is_left_upper_snake = isinstance(left, ast.Name) and _is_upper_snake_name(left.id)
183
+ is_right_upper_snake = isinstance(right, ast.Name) and _is_upper_snake_name(right.id)
184
+ if is_left_upper_snake and is_right_upper_snake:
185
+ return False
186
+ is_left_a_literal = isinstance(left, ast.Constant)
187
+ is_right_a_literal = isinstance(right, ast.Constant)
188
+ return (
189
+ (is_left_upper_snake and is_right_a_literal)
190
+ or (is_right_upper_snake and is_left_a_literal)
191
+ )
192
+
193
+
194
+ def check_constant_equality_tests(content: str, file_path: str) -> list[str]:
195
+ """Flag test functions whose sole assertion compares a constant to a literal.
196
+
197
+ Tests like 'assert CACHE_DIR == "cache"' cover no behavior — they just
198
+ verify the constant has not changed. Such tests should be deleted.
199
+ Only applies to test files; production files are exempt.
200
+ """
201
+ if not is_test_file(file_path):
202
+ return []
203
+
204
+ try:
205
+ syntax_tree = ast.parse(content)
206
+ except SyntaxError:
207
+ return []
208
+
209
+ issues: list[str] = []
210
+ for each_node in ast.walk(syntax_tree):
211
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
212
+ continue
213
+ if not each_node.name.startswith("test"):
214
+ continue
215
+ all_assertions = _collect_body_assertions(each_node.body)
216
+ if not all_assertions:
217
+ continue
218
+ if len(all_assertions) > 1:
219
+ continue
220
+ if _assert_is_constant_equality_only(all_assertions[0]):
221
+ issues.append(
222
+ f"Line {each_node.lineno}: constant-value test"
223
+ f" — delete; tests must cover behavior"
224
+ )
225
+
226
+ return issues
@@ -0,0 +1,181 @@
1
+ """Test-mode branching in production and bare-except checks."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
8
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
9
+ if _BLOCKING_DIRECTORY not in sys.path:
10
+ sys.path.insert(0, _BLOCKING_DIRECTORY)
11
+ if _HOOKS_DIRECTORY not in sys.path:
12
+ sys.path.insert(0, _HOOKS_DIRECTORY)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ _walk_skipping_type_checking_blocks,
16
+ is_hook_infrastructure,
17
+ is_test_file,
18
+ )
19
+
20
+ from hooks_constants.blocking_check_limits import ( # noqa: E402
21
+ ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES,
22
+ ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES,
23
+ MAX_BARE_EXCEPT_ISSUES,
24
+ MAX_TEST_BRANCHING_ISSUES,
25
+ )
26
+
27
+
28
+ def _string_constant_value(node: ast.expr) -> str | None:
29
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
30
+ return node.value
31
+ return None
32
+
33
+
34
+ def _is_environ_attribute(node: ast.expr) -> bool:
35
+ if isinstance(node, ast.Attribute) and node.attr == "environ":
36
+ return isinstance(node.value, ast.Name) and node.value.id == "os"
37
+ return False
38
+
39
+
40
+ def _environ_get_call_argument_names(call_node: ast.Call) -> list[str]:
41
+ function_node = call_node.func
42
+ if not isinstance(function_node, ast.Attribute):
43
+ return []
44
+ if function_node.attr != "get":
45
+ return []
46
+ if not _is_environ_attribute(function_node.value):
47
+ return []
48
+ if not call_node.args:
49
+ return []
50
+ first_argument = _string_constant_value(call_node.args[0])
51
+ return [first_argument] if first_argument is not None else []
52
+
53
+
54
+ def _environ_subscript_key_names(subscript_node: ast.Subscript) -> list[str]:
55
+ if not _is_environ_attribute(subscript_node.value):
56
+ return []
57
+ key = _string_constant_value(subscript_node.slice)
58
+ return [key] if key is not None else []
59
+
60
+
61
+ def _environ_membership_key_names(compare_node: ast.Compare) -> list[str]:
62
+ if not compare_node.ops:
63
+ return []
64
+ if not isinstance(compare_node.ops[0], (ast.In, ast.NotIn)):
65
+ return []
66
+ if not compare_node.comparators:
67
+ return []
68
+ if not _is_environ_attribute(compare_node.comparators[0]):
69
+ return []
70
+ key = _string_constant_value(compare_node.left)
71
+ return [key] if key is not None else []
72
+
73
+
74
+ def _collect_test_env_variable_references(parsed_tree: ast.AST) -> list[tuple[int, str]]:
75
+ references: list[tuple[int, str]] = []
76
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
77
+ candidate_names: list[str] = []
78
+ if isinstance(each_node, ast.Call):
79
+ candidate_names = _environ_get_call_argument_names(each_node)
80
+ elif isinstance(each_node, ast.Subscript):
81
+ candidate_names = _environ_subscript_key_names(each_node)
82
+ elif isinstance(each_node, ast.Compare):
83
+ candidate_names = _environ_membership_key_names(each_node)
84
+ for each_candidate_name in candidate_names:
85
+ if each_candidate_name in ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES:
86
+ references.append((each_node.lineno, each_candidate_name))
87
+ return references
88
+
89
+
90
+ def check_test_branching_in_production(content: str, file_path: str) -> list[str]:
91
+ """Flag production code that branches on TESTING-style env vars.
92
+
93
+ Production code reading TESTING / PYTEST_CURRENT_TEST creates two
94
+ parallel implementations and hides bugs. Use dependency injection
95
+ (override the dependency in tests) instead.
96
+ """
97
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
98
+ return []
99
+
100
+ try:
101
+ parsed_tree = ast.parse(content)
102
+ except SyntaxError:
103
+ return []
104
+
105
+ references = _collect_test_env_variable_references(parsed_tree)
106
+ references.sort(key=lambda each_reference: each_reference[0])
107
+
108
+ issues: list[str] = []
109
+ already_reported_lines: set[int] = set()
110
+ for each_line_number, each_variable_name in references:
111
+ if each_line_number in already_reported_lines:
112
+ continue
113
+ already_reported_lines.add(each_line_number)
114
+ issues.append(
115
+ f"Line {each_line_number}: Production code reads test indicator '{each_variable_name}' — "
116
+ "use dependency injection so production stays single-path"
117
+ )
118
+ if len(issues) >= MAX_TEST_BRANCHING_ISSUES:
119
+ break
120
+
121
+ return issues
122
+
123
+
124
+ def _bare_except_handler_label(handler_node: ast.ExceptHandler) -> str | None:
125
+ """Return a label for handlers we flag, or None for safe handlers."""
126
+ handler_type = handler_node.type
127
+ if handler_type is None:
128
+ return "bare except:"
129
+ if isinstance(handler_type, ast.Name) and handler_type.id in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES:
130
+ return f"except {handler_type.id}:"
131
+ if (
132
+ isinstance(handler_type, ast.Attribute)
133
+ and handler_type.attr in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES
134
+ ):
135
+ return f"except {handler_type.attr}:"
136
+ if isinstance(handler_type, ast.Tuple):
137
+ banned_names: list[str] = []
138
+ for each_element in handler_type.elts:
139
+ if isinstance(each_element, ast.Name) and each_element.id in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES:
140
+ banned_names.append(each_element.id)
141
+ elif (
142
+ isinstance(each_element, ast.Attribute)
143
+ and each_element.attr in ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES
144
+ ):
145
+ banned_names.append(each_element.attr)
146
+ if banned_names:
147
+ return f"except {', '.join(banned_names)} (in tuple):"
148
+ return None
149
+
150
+
151
+ def check_bare_except(content: str, file_path: str) -> list[str]:
152
+ """Flag bare/over-broad exception handlers in production code.
153
+
154
+ ``except:`` and ``except BaseException:`` swallow KeyboardInterrupt and
155
+ SystemExit; ``except Exception:`` hides bugs by catching nearly every
156
+ error class. Production code should name the specific exception(s) it
157
+ intends to catch
158
+ (a tuple form like `except (ValueError, KeyError):` is fine).
159
+ """
160
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
161
+ return []
162
+
163
+ try:
164
+ parsed_tree = ast.parse(content)
165
+ except SyntaxError:
166
+ return []
167
+
168
+ issues: list[str] = []
169
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
170
+ if not isinstance(each_node, ast.ExceptHandler):
171
+ continue
172
+ handler_label = _bare_except_handler_label(each_node)
173
+ if handler_label is None:
174
+ continue
175
+ issues.append(
176
+ f"Line {each_node.lineno}: {handler_label} is over-broad — name the "
177
+ "specific exception(s) you intend to handle"
178
+ )
179
+ if len(issues) >= MAX_BARE_EXCEPT_ISSUES:
180
+ break
181
+ return issues