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,225 @@
|
|
|
1
|
+
"""Pytest test-function collection, isolation-fixture detection, and probe recording for the test-isolation check."""
|
|
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_probe_chains import ( # noqa: E402
|
|
15
|
+
_descend_within_test_scope,
|
|
16
|
+
_dotted_attribute_chain,
|
|
17
|
+
_dotted_call_attribute_chain,
|
|
18
|
+
_environ_key_string_from_call,
|
|
19
|
+
_environ_key_string_from_subscript,
|
|
20
|
+
_resolve_chain_through_aliases,
|
|
21
|
+
)
|
|
22
|
+
from code_rules_probe_detection import ( # noqa: E402
|
|
23
|
+
_expanduser_argument_references_home,
|
|
24
|
+
_expanduser_method_call_targets_pathlib_path,
|
|
25
|
+
_expandvars_argument_references_home_or_temp,
|
|
26
|
+
_tempfile_factory_call_is_isolated_by_dir,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
30
|
+
ALL_DIR_ACCEPTING_TEMPFILE_FACTORY_DOTTED_NAMES,
|
|
31
|
+
ALL_FILESYSTEM_HOME_PROBE_DOTTED_NAMES,
|
|
32
|
+
ALL_HOME_DIRECTORY_ENV_VAR_NAMES,
|
|
33
|
+
ALL_PATHLIB_STATIC_EXPANDUSER_DOTTED_NAMES,
|
|
34
|
+
ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES,
|
|
35
|
+
EXPANDUSER_DOTTED_NAME,
|
|
36
|
+
EXPANDVARS_DOTTED_NAME,
|
|
37
|
+
PATHLIB_EXPANDUSER_METHOD_NAME,
|
|
38
|
+
PYTEST_TEST_CLASS_NAME_PREFIX,
|
|
39
|
+
PYTEST_USEFIXTURES_MARKER_NAME,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _collect_pytest_collectable_test_functions(
|
|
44
|
+
syntax_tree: ast.Module,
|
|
45
|
+
) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
46
|
+
"""Enumerate the function nodes pytest would actually collect as tests.
|
|
47
|
+
|
|
48
|
+
Walks module-level statements and the top-level methods of module-level
|
|
49
|
+
classes only. Functions nested inside other functions or lambdas are
|
|
50
|
+
excluded because pytest does not collect nested callables. Module-level
|
|
51
|
+
classes whose name does not start with the
|
|
52
|
+
``PYTEST_TEST_CLASS_NAME_PREFIX`` (``Test``) are skipped because the
|
|
53
|
+
repo's ``pytest.ini`` declares ``python_classes = Test*``; methods on
|
|
54
|
+
non-``Test*`` helper classes are never collected by pytest.
|
|
55
|
+
"""
|
|
56
|
+
collectable: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
57
|
+
for each_module_statement in syntax_tree.body:
|
|
58
|
+
if isinstance(each_module_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
59
|
+
if (
|
|
60
|
+
each_module_statement.name.startswith("test_")
|
|
61
|
+
or each_module_statement.name.startswith("should_")
|
|
62
|
+
):
|
|
63
|
+
collectable.append(each_module_statement)
|
|
64
|
+
elif isinstance(each_module_statement, ast.ClassDef):
|
|
65
|
+
if not each_module_statement.name.startswith(PYTEST_TEST_CLASS_NAME_PREFIX):
|
|
66
|
+
continue
|
|
67
|
+
for each_class_member in each_module_statement.body:
|
|
68
|
+
if isinstance(each_class_member, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
|
69
|
+
each_class_member.name.startswith("test_")
|
|
70
|
+
or each_class_member.name.startswith("should_")
|
|
71
|
+
):
|
|
72
|
+
collectable.append(each_class_member)
|
|
73
|
+
return collectable
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _detect_home_or_temp_probes_in_body(
|
|
77
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
78
|
+
all_canonical_names_by_alias: dict[str, str],
|
|
79
|
+
all_environ_local_bindings: set[str],
|
|
80
|
+
all_path_local_bindings: set[str],
|
|
81
|
+
) -> list[tuple[int, str]]:
|
|
82
|
+
"""Yield ``(line, probe_label)`` pairs for HOME/TMP probes in *function_node*.
|
|
83
|
+
|
|
84
|
+
The walk descends into ``ClassDef`` nodes nested inside the test body and
|
|
85
|
+
into their class-level statements. Class-level statements (class attribute
|
|
86
|
+
initializers) run at class-creation time as the ``class`` statement
|
|
87
|
+
executes during the test, so a probe in an initializer such as ``root =
|
|
88
|
+
Path.home()`` is on the test's runtime path and is reported. A method of a
|
|
89
|
+
nested class is a callable-scope boundary: Python does not run a method
|
|
90
|
+
just because its class is defined, so the walk does not descend into method
|
|
91
|
+
bodies. Standalone nested helper functions and lambdas defined anywhere are
|
|
92
|
+
likewise scope boundaries — each runs in its own callable scope and carries
|
|
93
|
+
its own isolation contract. Probes that genuinely execute on the test path
|
|
94
|
+
(top-level statements and class-level initializers) are still detected.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
function_node: The test function whose body is being scanned.
|
|
98
|
+
all_canonical_names_by_alias: Local-binding-to-canonical-prefix mapping used to resolve
|
|
99
|
+
aliased imports before probe membership checks.
|
|
100
|
+
all_environ_local_bindings: Local names bound to ``os.environ`` (scoped
|
|
101
|
+
to *function_node*) used to attribute subscript and ``.get(...)``
|
|
102
|
+
reads to a HOME/TMP env probe.
|
|
103
|
+
all_path_local_bindings: Local names bound to a ``pathlib.Path``
|
|
104
|
+
construction (scoped to *function_node*) used to attribute a
|
|
105
|
+
``.expanduser()`` method call to a home-directory probe.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A list of ``(line_number, probe_label)`` tuples for each HOME/TMP
|
|
109
|
+
probe attributed to the test, in stack-pop order.
|
|
110
|
+
"""
|
|
111
|
+
probes: list[tuple[int, str]] = []
|
|
112
|
+
for each_descendant in _descend_within_test_scope(function_node):
|
|
113
|
+
_record_home_or_temp_probe(
|
|
114
|
+
each_descendant,
|
|
115
|
+
probes,
|
|
116
|
+
all_canonical_names_by_alias,
|
|
117
|
+
all_environ_local_bindings,
|
|
118
|
+
all_path_local_bindings,
|
|
119
|
+
)
|
|
120
|
+
probes.sort(key=lambda each_probe: each_probe[0])
|
|
121
|
+
return probes
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _record_home_or_temp_probe(
|
|
125
|
+
node: ast.AST,
|
|
126
|
+
all_probes: list[tuple[int, str]],
|
|
127
|
+
all_canonical_names_by_alias: dict[str, str],
|
|
128
|
+
all_environ_local_bindings: set[str],
|
|
129
|
+
all_path_local_bindings: set[str],
|
|
130
|
+
) -> None:
|
|
131
|
+
if isinstance(node, ast.Call):
|
|
132
|
+
if _expanduser_method_call_targets_pathlib_path(
|
|
133
|
+
node, all_canonical_names_by_alias, all_path_local_bindings
|
|
134
|
+
):
|
|
135
|
+
all_probes.append((node.lineno, f"Path.{PATHLIB_EXPANDUSER_METHOD_NAME}()"))
|
|
136
|
+
return
|
|
137
|
+
raw_chain = _dotted_call_attribute_chain(node)
|
|
138
|
+
if raw_chain is None:
|
|
139
|
+
return
|
|
140
|
+
canonical_chain = _resolve_chain_through_aliases(raw_chain, all_canonical_names_by_alias)
|
|
141
|
+
if canonical_chain == EXPANDVARS_DOTTED_NAME:
|
|
142
|
+
if _expandvars_argument_references_home_or_temp(node):
|
|
143
|
+
all_probes.append((node.lineno, f"{canonical_chain}()"))
|
|
144
|
+
return
|
|
145
|
+
if canonical_chain == EXPANDUSER_DOTTED_NAME:
|
|
146
|
+
if _expanduser_argument_references_home(node):
|
|
147
|
+
all_probes.append((node.lineno, f"{canonical_chain}()"))
|
|
148
|
+
return
|
|
149
|
+
if canonical_chain in ALL_PATHLIB_STATIC_EXPANDUSER_DOTTED_NAMES:
|
|
150
|
+
if _expanduser_argument_references_home(node):
|
|
151
|
+
all_probes.append((node.lineno, f"{canonical_chain}()"))
|
|
152
|
+
return
|
|
153
|
+
if canonical_chain in ALL_FILESYSTEM_HOME_PROBE_DOTTED_NAMES:
|
|
154
|
+
if (
|
|
155
|
+
canonical_chain in ALL_DIR_ACCEPTING_TEMPFILE_FACTORY_DOTTED_NAMES
|
|
156
|
+
and _tempfile_factory_call_is_isolated_by_dir(
|
|
157
|
+
node, all_canonical_names_by_alias, all_environ_local_bindings
|
|
158
|
+
)
|
|
159
|
+
):
|
|
160
|
+
return
|
|
161
|
+
all_probes.append((node.lineno, f"{canonical_chain}()"))
|
|
162
|
+
return
|
|
163
|
+
environ_key = _environ_key_string_from_call(
|
|
164
|
+
node, all_canonical_names_by_alias, all_environ_local_bindings
|
|
165
|
+
)
|
|
166
|
+
if environ_key in ALL_HOME_DIRECTORY_ENV_VAR_NAMES:
|
|
167
|
+
all_probes.append((node.lineno, f"os env probe '{environ_key}'"))
|
|
168
|
+
return
|
|
169
|
+
if isinstance(node, ast.Subscript):
|
|
170
|
+
environ_key = _environ_key_string_from_subscript(
|
|
171
|
+
node, all_canonical_names_by_alias, all_environ_local_bindings
|
|
172
|
+
)
|
|
173
|
+
if environ_key in ALL_HOME_DIRECTORY_ENV_VAR_NAMES:
|
|
174
|
+
all_probes.append((node.lineno, f"os.environ['{environ_key}']"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _usefixtures_decorator_requests_isolation_fixture(decorator_node: ast.expr) -> bool:
|
|
178
|
+
"""Report whether a decorator is ``usefixtures`` requesting an isolation fixture.
|
|
179
|
+
|
|
180
|
+
Recognizes ``@pytest.mark.usefixtures("monkeypatch")`` and the
|
|
181
|
+
``@mark.usefixtures("monkeypatch")`` short form: an ``ast.Call`` whose callee
|
|
182
|
+
attribute chain ends in ``usefixtures`` and whose string-constant arguments
|
|
183
|
+
include any name in ``ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES``.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
decorator_node: A single decorator expression from a test's decorator list.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True when the decorator injects an isolation fixture by name.
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(decorator_node, ast.Call):
|
|
192
|
+
return False
|
|
193
|
+
if not isinstance(decorator_node.func, ast.Attribute):
|
|
194
|
+
return False
|
|
195
|
+
callee_chain = _dotted_attribute_chain(decorator_node.func)
|
|
196
|
+
if callee_chain is None:
|
|
197
|
+
return False
|
|
198
|
+
if not callee_chain.endswith(PYTEST_USEFIXTURES_MARKER_NAME):
|
|
199
|
+
return False
|
|
200
|
+
for each_argument in decorator_node.args:
|
|
201
|
+
if (
|
|
202
|
+
isinstance(each_argument, ast.Constant)
|
|
203
|
+
and isinstance(each_argument.value, str)
|
|
204
|
+
and each_argument.value in ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES
|
|
205
|
+
):
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _function_uses_pytest_isolation_fixture(
|
|
211
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
212
|
+
) -> bool:
|
|
213
|
+
for each_argument in function_node.args.posonlyargs:
|
|
214
|
+
if each_argument.arg in ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES:
|
|
215
|
+
return True
|
|
216
|
+
for each_argument in function_node.args.args:
|
|
217
|
+
if each_argument.arg in ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES:
|
|
218
|
+
return True
|
|
219
|
+
for each_argument in function_node.args.kwonlyargs:
|
|
220
|
+
if each_argument.arg in ALL_PYTEST_FILESYSTEM_ISOLATION_FIXTURE_NAMES:
|
|
221
|
+
return True
|
|
222
|
+
for each_decorator in function_node.decorator_list:
|
|
223
|
+
if _usefixtures_decorator_requests_isolation_fixture(each_decorator):
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Lexical scope-binding collectors used by the unused-import check."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
from hooks_constants.stuttering_import_binding_constants import (
|
|
6
|
+
WILDCARD_IMPORT_SENTINEL,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _attribute_root_name_if_loaded(attribute_node: ast.Attribute) -> ast.Name | None:
|
|
11
|
+
current: ast.expr = attribute_node
|
|
12
|
+
while isinstance(current, ast.Attribute):
|
|
13
|
+
current = current.value
|
|
14
|
+
if isinstance(current, ast.Name) and isinstance(current.ctx, ast.Load):
|
|
15
|
+
return current
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ScopeBindingCollector(ast.NodeVisitor):
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.binding_names: set[str] = set()
|
|
22
|
+
self.global_names: set[str] = set()
|
|
23
|
+
|
|
24
|
+
def collect_arguments(self, arguments: ast.arguments) -> None:
|
|
25
|
+
for each_argument in (
|
|
26
|
+
arguments.posonlyargs
|
|
27
|
+
+ arguments.args
|
|
28
|
+
+ arguments.kwonlyargs
|
|
29
|
+
):
|
|
30
|
+
self.binding_names.add(each_argument.arg)
|
|
31
|
+
if arguments.vararg is not None:
|
|
32
|
+
self.binding_names.add(arguments.vararg.arg)
|
|
33
|
+
if arguments.kwarg is not None:
|
|
34
|
+
self.binding_names.add(arguments.kwarg.arg)
|
|
35
|
+
|
|
36
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
37
|
+
self.global_names.update(node.names)
|
|
38
|
+
|
|
39
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
40
|
+
self.binding_names.update(node.names)
|
|
41
|
+
|
|
42
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
43
|
+
self.binding_names.add(node.name)
|
|
44
|
+
|
|
45
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
46
|
+
self.binding_names.add(node.name)
|
|
47
|
+
|
|
48
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
49
|
+
self.binding_names.add(node.name)
|
|
50
|
+
|
|
51
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
55
|
+
if isinstance(node.ctx, ast.Store):
|
|
56
|
+
self.binding_names.add(node.id)
|
|
57
|
+
|
|
58
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
59
|
+
for each_alias in node.names:
|
|
60
|
+
self.binding_names.add(each_alias.asname or each_alias.name.split(".")[0])
|
|
61
|
+
|
|
62
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
63
|
+
for each_alias in node.names:
|
|
64
|
+
if each_alias.name != WILDCARD_IMPORT_SENTINEL:
|
|
65
|
+
self.binding_names.add(each_alias.asname or each_alias.name)
|
|
66
|
+
|
|
67
|
+
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
80
|
+
if node.name is not None:
|
|
81
|
+
self.binding_names.add(node.name)
|
|
82
|
+
self.generic_visit(node)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _scope_binding_names(scope_node: ast.AST) -> tuple[set[str], set[str]]:
|
|
86
|
+
collector = _ScopeBindingCollector()
|
|
87
|
+
if isinstance(scope_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
88
|
+
collector.collect_arguments(scope_node.args)
|
|
89
|
+
for each_statement in scope_node.body:
|
|
90
|
+
collector.visit(each_statement)
|
|
91
|
+
elif isinstance(scope_node, ast.Lambda):
|
|
92
|
+
collector.collect_arguments(scope_node.args)
|
|
93
|
+
collector.visit(scope_node.body)
|
|
94
|
+
elif isinstance(scope_node, ast.ClassDef):
|
|
95
|
+
for each_statement in scope_node.body:
|
|
96
|
+
collector.visit(each_statement)
|
|
97
|
+
return collector.binding_names, collector.global_names
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_name_is_shadowed(
|
|
101
|
+
load_node: ast.AST,
|
|
102
|
+
name: str,
|
|
103
|
+
parent_by_node_id: dict[int, ast.AST],
|
|
104
|
+
) -> bool:
|
|
105
|
+
current = parent_by_node_id.get(id(load_node))
|
|
106
|
+
has_passed_function_scope = False
|
|
107
|
+
while current is not None:
|
|
108
|
+
if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
109
|
+
has_passed_function_scope = True
|
|
110
|
+
binding_names, global_names = _scope_binding_names(current)
|
|
111
|
+
if name in global_names:
|
|
112
|
+
return False
|
|
113
|
+
if name in binding_names:
|
|
114
|
+
return True
|
|
115
|
+
elif isinstance(current, ast.ClassDef) and not has_passed_function_scope:
|
|
116
|
+
# Class body bindings are order-dependent (name resolution is
|
|
117
|
+
# dynamic, unlike function locals). A load before an assignment
|
|
118
|
+
# still resolves to the module-level name, so conservatively
|
|
119
|
+
# skip class-body shadow detection to avoid false positives.
|
|
120
|
+
pass
|
|
121
|
+
current = parent_by_node_id.get(id(current))
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _names_from_annotation_text(annotation_text: str) -> set[str]:
|
|
126
|
+
try:
|
|
127
|
+
annotation_tree = ast.parse(annotation_text, mode="eval")
|
|
128
|
+
except SyntaxError:
|
|
129
|
+
return set()
|
|
130
|
+
referenced_names: set[str] = set()
|
|
131
|
+
for each_node in ast.walk(annotation_tree):
|
|
132
|
+
if isinstance(each_node, ast.Name):
|
|
133
|
+
referenced_names.add(each_node.id)
|
|
134
|
+
elif isinstance(each_node, ast.Attribute):
|
|
135
|
+
root_name = _attribute_root_name_if_loaded(each_node)
|
|
136
|
+
if root_name is not None:
|
|
137
|
+
referenced_names.add(root_name.id)
|
|
138
|
+
return referenced_names
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _collect_string_annotation_names(tree: ast.Module) -> set[str]:
|
|
142
|
+
referenced_names: set[str] = set()
|
|
143
|
+
for each_node in ast.walk(tree):
|
|
144
|
+
annotation = None
|
|
145
|
+
if isinstance(each_node, ast.arg):
|
|
146
|
+
annotation = each_node.annotation
|
|
147
|
+
elif isinstance(each_node, (ast.AnnAssign, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
148
|
+
annotation = each_node.annotation if isinstance(each_node, ast.AnnAssign) else each_node.returns
|
|
149
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
150
|
+
referenced_names.update(_names_from_annotation_text(annotation.value))
|
|
151
|
+
return referenced_names
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Shared file classifiers, AST-walk helpers, and diff-scoping utilities for the code-rules checks."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import difflib
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
10
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
11
|
+
if _blocking_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _blocking_directory)
|
|
13
|
+
if _hooks_directory not in sys.path:
|
|
14
|
+
sys.path.insert(0, _hooks_directory)
|
|
15
|
+
|
|
16
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
17
|
+
ALL_DIFF_CHANGED_OPCODE_TAGS,
|
|
18
|
+
ALL_HOOK_INFRASTRUCTURE_PATTERNS,
|
|
19
|
+
ALL_MIGRATION_PATH_PATTERNS,
|
|
20
|
+
ALL_TEST_PATH_PATTERNS,
|
|
21
|
+
ALL_WORKFLOW_REGISTRY_PATTERNS,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
24
|
+
TYPE_CHECKING_IDENTIFIER,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_file_extension(file_path: str) -> str:
|
|
29
|
+
"""Extract lowercase file extension."""
|
|
30
|
+
dot_index = file_path.rfind(".")
|
|
31
|
+
if dot_index == -1:
|
|
32
|
+
return ""
|
|
33
|
+
return file_path[dot_index:].lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_hook_infrastructure(file_path: str) -> bool:
|
|
37
|
+
"""Check if file is a Claude Code hook (standalone infrastructure, not project code)."""
|
|
38
|
+
path_lower = "/" + file_path.lower().replace("\\", "/").lstrip("/")
|
|
39
|
+
return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_HOOK_INFRASTRUCTURE_PATTERNS)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_test_file(file_path: str) -> bool:
|
|
43
|
+
"""Check if file is a test file."""
|
|
44
|
+
path_lower = file_path.lower()
|
|
45
|
+
basename_lower = path_lower.replace("\\", "/").rsplit("/", 1)[-1]
|
|
46
|
+
if basename_lower == "conftest.py":
|
|
47
|
+
return True
|
|
48
|
+
return any(pattern in path_lower for pattern in ALL_TEST_PATH_PATTERNS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_workflow_registry_file(file_path: str) -> bool:
|
|
52
|
+
"""Check if file is a workflow state/module registry file.
|
|
53
|
+
|
|
54
|
+
Workflow tab files and state/module registry files use UPPER_SNAKE naming
|
|
55
|
+
for StateDefinition and WorkflowModule instances by architectural convention.
|
|
56
|
+
These are module-level singletons, not misplaced literal constants.
|
|
57
|
+
"""
|
|
58
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
59
|
+
return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_WORKFLOW_REGISTRY_PATTERNS)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_spec_file(file_path: str) -> bool:
|
|
63
|
+
"""Check if file is an E2E spec file."""
|
|
64
|
+
return ".spec." in file_path.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_type_checking_guard(if_node: ast.If) -> bool:
|
|
68
|
+
test_node = if_node.test
|
|
69
|
+
if isinstance(test_node, ast.Name) and test_node.id == TYPE_CHECKING_IDENTIFIER:
|
|
70
|
+
return True
|
|
71
|
+
return isinstance(test_node, ast.Attribute) and test_node.attr == TYPE_CHECKING_IDENTIFIER
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _walk_skipping_type_checking_blocks(node: ast.AST) -> "Iterator[ast.AST]":
|
|
75
|
+
for each_child in ast.iter_child_nodes(node):
|
|
76
|
+
if isinstance(each_child, ast.If) and _is_type_checking_guard(each_child):
|
|
77
|
+
continue
|
|
78
|
+
yield each_child
|
|
79
|
+
yield from _walk_skipping_type_checking_blocks(each_child)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _walk_skipping_nested_functions(node: ast.AST) -> "Iterator[ast.AST]":
|
|
83
|
+
for each_child in ast.iter_child_nodes(node):
|
|
84
|
+
if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
85
|
+
continue
|
|
86
|
+
yield each_child
|
|
87
|
+
yield from _walk_skipping_nested_functions(each_child)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _walk_skipping_nested_function_defs(start_node: ast.AST) -> Iterator[ast.AST]:
|
|
91
|
+
if isinstance(start_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
92
|
+
return
|
|
93
|
+
nodes_to_visit: list[ast.AST] = [start_node]
|
|
94
|
+
while nodes_to_visit:
|
|
95
|
+
current_node = nodes_to_visit.pop()
|
|
96
|
+
yield current_node
|
|
97
|
+
all_child_nodes = list(ast.iter_child_nodes(current_node))
|
|
98
|
+
for each_child_node in reversed(all_child_nodes):
|
|
99
|
+
if isinstance(each_child_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
100
|
+
continue
|
|
101
|
+
nodes_to_visit.append(each_child_node)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _collect_annotated_arguments(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.arg]:
|
|
105
|
+
"""Return every argument node on a function that may carry an annotation."""
|
|
106
|
+
arguments = function_node.args
|
|
107
|
+
all_annotated_arguments: list[ast.arg] = []
|
|
108
|
+
all_annotated_arguments.extend(arguments.posonlyargs)
|
|
109
|
+
all_annotated_arguments.extend(arguments.args)
|
|
110
|
+
all_annotated_arguments.extend(arguments.kwonlyargs)
|
|
111
|
+
if arguments.vararg is not None:
|
|
112
|
+
all_annotated_arguments.append(arguments.vararg)
|
|
113
|
+
if arguments.kwarg is not None:
|
|
114
|
+
all_annotated_arguments.append(arguments.kwarg)
|
|
115
|
+
return all_annotated_arguments
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _collect_target_names(target: ast.expr) -> list[ast.Name]:
|
|
119
|
+
"""Return every ast.Name reachable through tuple/list/starred unpacking targets."""
|
|
120
|
+
if isinstance(target, ast.Name):
|
|
121
|
+
return [target]
|
|
122
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
123
|
+
names: list[ast.Name] = []
|
|
124
|
+
for each_element in target.elts:
|
|
125
|
+
names.extend(_collect_target_names(each_element))
|
|
126
|
+
return names
|
|
127
|
+
if isinstance(target, ast.Starred):
|
|
128
|
+
return _collect_target_names(target.value)
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extract_fstring_literal_parts(
|
|
133
|
+
joined_string_node: ast.JoinedStr,
|
|
134
|
+
interpolation_placeholder: str = "INTERP",
|
|
135
|
+
) -> tuple[str, str]:
|
|
136
|
+
"""Return (display_body, shape_body) for an f-string node.
|
|
137
|
+
|
|
138
|
+
``display_body`` concatenates only the literal segments for use in the
|
|
139
|
+
human-readable flag message. ``shape_body`` substitutes each interpolation
|
|
140
|
+
slot with ``interpolation_placeholder`` so callers can choose a token that
|
|
141
|
+
both preserves structural shape and does not collide with literal text in
|
|
142
|
+
the source. The default ``"INTERP"`` keeps regex patterns for path shape
|
|
143
|
+
(``\\w+/\\w+/\\w+``) matching across interpolation boundaries
|
|
144
|
+
(e.g. ``/api/v1/{id}/home`` keeps its three path segments instead of
|
|
145
|
+
collapsing to ``/api/v1//home``). Callers that will compare shape bodies
|
|
146
|
+
verbatim — such as the skeleton builder — should pass their final token
|
|
147
|
+
here directly rather than post-processing with ``.replace``, since that
|
|
148
|
+
would corrupt literal text containing the default placeholder. Escaped
|
|
149
|
+
braces (``{{`` / ``}}``) are already decoded by :mod:`ast` into their
|
|
150
|
+
literal forms.
|
|
151
|
+
"""
|
|
152
|
+
display_segments: list[str] = []
|
|
153
|
+
shape_segments: list[str] = []
|
|
154
|
+
for each_part in joined_string_node.values:
|
|
155
|
+
if isinstance(each_part, ast.Constant) and isinstance(each_part.value, str):
|
|
156
|
+
display_segments.append(each_part.value)
|
|
157
|
+
shape_segments.append(each_part.value)
|
|
158
|
+
else:
|
|
159
|
+
shape_segments.append(interpolation_placeholder)
|
|
160
|
+
return "".join(display_segments), "".join(shape_segments)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_migration_file(file_path: str) -> bool:
|
|
164
|
+
"""Check if file is a Django migration (must be self-contained)."""
|
|
165
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
166
|
+
return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_MIGRATION_PATH_PATTERNS)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_parent_map(module_tree: ast.Module) -> dict[int, ast.AST]:
|
|
170
|
+
"""Map child node id() to its parent node for ancestor walking."""
|
|
171
|
+
parent_by_child_id: dict[int, ast.AST] = {}
|
|
172
|
+
for each_parent in ast.walk(module_tree):
|
|
173
|
+
for each_child in ast.iter_child_nodes(each_parent):
|
|
174
|
+
parent_by_child_id[id(each_child)] = each_parent
|
|
175
|
+
return parent_by_child_id
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _statement_is_docstring(statement_node: ast.stmt) -> bool:
|
|
179
|
+
return (
|
|
180
|
+
isinstance(statement_node, ast.Expr)
|
|
181
|
+
and isinstance(statement_node.value, ast.Constant)
|
|
182
|
+
and isinstance(statement_node.value.value, str)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _function_definition_line_span(
|
|
187
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
188
|
+
) -> int:
|
|
189
|
+
end_lineno = getattr(function_node, "end_lineno", None) or function_node.lineno
|
|
190
|
+
return end_lineno - function_node.lineno + 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _definition_docstring_line_span(
|
|
194
|
+
definition_node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
195
|
+
) -> int:
|
|
196
|
+
"""Return the source-line count of the definition's leading docstring.
|
|
197
|
+
|
|
198
|
+
The Google Python Style Guide pairs a small-function preference that
|
|
199
|
+
targets executable complexity with a requirement for complete docstrings
|
|
200
|
+
on public functions and classes. Counting those docstring lines toward the
|
|
201
|
+
function-length gate would penalize the very documentation the Guide
|
|
202
|
+
mandates, so the gate measures executable span and excludes leading
|
|
203
|
+
docstring statements.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
definition_node: The function, method, or class definition node to
|
|
207
|
+
inspect.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The number of source lines the leading docstring statement occupies,
|
|
211
|
+
or zero when the definition body is empty or does not open with a
|
|
212
|
+
string literal.
|
|
213
|
+
"""
|
|
214
|
+
definition_body = definition_node.body
|
|
215
|
+
if not definition_body:
|
|
216
|
+
return 0
|
|
217
|
+
first_statement = definition_body[0]
|
|
218
|
+
if _statement_is_docstring(first_statement):
|
|
219
|
+
docstring_end = getattr(first_statement, "end_lineno", None) or first_statement.lineno
|
|
220
|
+
return docstring_end - first_statement.lineno + 1
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def changed_line_numbers(prior_content: str, post_edit_content: str) -> set[int]:
|
|
225
|
+
"""Return the post-edit line numbers an edit added or replaced.
|
|
226
|
+
|
|
227
|
+
Runs a line-level diff of *prior_content* against *post_edit_content* and
|
|
228
|
+
collects the 1-indexed line numbers in *post_edit_content* that fall inside
|
|
229
|
+
a ``replace`` or ``insert`` opcode. This mirrors the "added lines" notion
|
|
230
|
+
that ``code_rules_gate.parse_added_line_numbers`` derives from
|
|
231
|
+
``git diff --unified=0``, so the PreToolUse layer and the gate agree on
|
|
232
|
+
which lines the change touched.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
prior_content: The file content before the edit.
|
|
236
|
+
post_edit_content: The reconstructed file content after the edit.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The set of 1-indexed line numbers in *post_edit_content* that the edit
|
|
240
|
+
added or replaced.
|
|
241
|
+
"""
|
|
242
|
+
matcher = difflib.SequenceMatcher(
|
|
243
|
+
a=prior_content.splitlines(),
|
|
244
|
+
b=post_edit_content.splitlines(),
|
|
245
|
+
autojunk=False,
|
|
246
|
+
)
|
|
247
|
+
all_changed_lines: set[int] = set()
|
|
248
|
+
for each_tag, _, _, each_post_start, each_post_end in matcher.get_opcodes():
|
|
249
|
+
if each_tag in ALL_DIFF_CHANGED_OPCODE_TAGS:
|
|
250
|
+
for each_post_index in range(each_post_start, each_post_end):
|
|
251
|
+
all_changed_lines.add(each_post_index + 1)
|
|
252
|
+
return all_changed_lines
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _scope_violations_to_changed_lines(
|
|
256
|
+
all_violations_in_walk_order: list[tuple[range, str]],
|
|
257
|
+
all_changed_lines: set[int] | None,
|
|
258
|
+
defer_scope_to_caller: bool = False,
|
|
259
|
+
) -> list[str]:
|
|
260
|
+
"""Scope span-tagged violations by diff intersection.
|
|
261
|
+
|
|
262
|
+
In-scope violations are always reported; the untouched out-of-scope set is
|
|
263
|
+
surfaced or dropped according to which caller path is active:
|
|
264
|
+
|
|
265
|
+
- ``defer_scope_to_caller`` True (the commit/push gate): every violation is
|
|
266
|
+
returned in walk order so the gate's ``split_violations_by_scope`` can
|
|
267
|
+
classify blocking vs advisory by added line. The gate does this scoping,
|
|
268
|
+
so no scoping happens here.
|
|
269
|
+
- ``all_changed_lines`` None (a terminal new-file or full-file write): every
|
|
270
|
+
line was just authored, so every violation is in scope and returned.
|
|
271
|
+
- ``all_changed_lines`` provided (a terminal diff-scoped Edit): only the
|
|
272
|
+
in-scope violations whose span intersects the changed lines are returned;
|
|
273
|
+
the untouched out-of-scope set is dropped, because untouched code must not
|
|
274
|
+
block a single-file edit.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
all_violations_in_walk_order: ``(span_range, issue_message)`` pairs in
|
|
278
|
+
``ast.walk`` traversal order, where ``span_range`` covers the
|
|
279
|
+
violation's source lines.
|
|
280
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
281
|
+
None to treat every violation as in-scope.
|
|
282
|
+
defer_scope_to_caller: When True, return every violation message in walk
|
|
283
|
+
order so the gate scopes by added line. When False, this enforcer is
|
|
284
|
+
terminal and scopes directly.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Every violation message when *defer_scope_to_caller* is True or
|
|
288
|
+
*all_changed_lines* is None; otherwise only the in-scope messages whose
|
|
289
|
+
span intersects the changed lines — so an edit that grows a function
|
|
290
|
+
past the threshold always blocks even when many earlier untouched
|
|
291
|
+
functions already exceed it.
|
|
292
|
+
"""
|
|
293
|
+
if defer_scope_to_caller:
|
|
294
|
+
return [each_message for _, each_message in all_violations_in_walk_order]
|
|
295
|
+
if all_changed_lines is None:
|
|
296
|
+
return [each_message for _, each_message in all_violations_in_walk_order]
|
|
297
|
+
return [
|
|
298
|
+
each_message
|
|
299
|
+
for each_span, each_message in all_violations_in_walk_order
|
|
300
|
+
if any(each_line in all_changed_lines for each_line in each_span)
|
|
301
|
+
]
|