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,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
|