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