claude-dev-env 1.50.1 → 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 (63) hide show
  1. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  2. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  3. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  4. package/hooks/blocking/code_rules_comments.py +337 -0
  5. package/hooks/blocking/code_rules_constants_config.py +252 -0
  6. package/hooks/blocking/code_rules_docstrings.py +308 -0
  7. package/hooks/blocking/code_rules_enforcer.py +98 -5807
  8. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  9. package/hooks/blocking/code_rules_magic_values.py +180 -0
  10. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  11. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  12. package/hooks/blocking/code_rules_optional_params.py +288 -0
  13. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  14. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  15. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  16. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  17. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  18. package/hooks/blocking/code_rules_shared.py +301 -0
  19. package/hooks/blocking/code_rules_string_magic.py +207 -0
  20. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  21. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  22. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  23. package/hooks/blocking/code_rules_type_escape.py +341 -0
  24. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  25. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  26. package/hooks/blocking/tdd_enforcer.py +31 -0
  27. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  28. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  30. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  31. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  32. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  33. package/hooks/blocking/test_code_rules_enforcer_function_length.py +18 -13
  34. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  35. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  36. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  37. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  38. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  39. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  40. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  41. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  42. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  43. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  56. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  57. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  58. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  59. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  60. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  61. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  62. package/package.json +1 -1
  63. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
@@ -0,0 +1,167 @@
1
+ """Parameter-annotation, return-annotation, and function-length 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
+ _collect_annotated_arguments,
16
+ _definition_docstring_line_span,
17
+ _function_definition_line_span,
18
+ _scope_violations_to_changed_lines,
19
+ is_hook_infrastructure,
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_SELF_AND_CLS_PARAMETER_NAMES,
27
+ FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
28
+ FUNCTION_LENGTH_BLOCKING_THRESHOLD,
29
+ )
30
+
31
+
32
+ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
33
+ if is_test_file(file_path):
34
+ return []
35
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
36
+ return []
37
+ try:
38
+ tree = ast.parse(content)
39
+ except SyntaxError:
40
+ return []
41
+ issues: list[str] = []
42
+ for each_node in ast.walk(tree):
43
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
44
+ continue
45
+ for each_arg in _collect_annotated_arguments(each_node):
46
+ if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
47
+ continue
48
+ if each_arg.annotation is None:
49
+ issues.append(
50
+ f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on {each_node.name!r} missing type annotation (CODE_RULES §6)"
51
+ )
52
+ return issues
53
+
54
+
55
+ def check_return_annotations(content: str, file_path: str) -> list[str]:
56
+ if is_test_file(file_path):
57
+ return []
58
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
59
+ return []
60
+ try:
61
+ tree = ast.parse(content)
62
+ except SyntaxError:
63
+ return []
64
+ issues: list[str] = []
65
+ for each_node in ast.walk(tree):
66
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
67
+ continue
68
+ if each_node.returns is None:
69
+ issues.append(
70
+ f"Line {each_node.lineno}: function {each_node.name!r} missing return type annotation (CODE_RULES §6)"
71
+ )
72
+ return issues
73
+
74
+
75
+ def check_function_length(
76
+ content: str,
77
+ file_path: str,
78
+ all_changed_lines: set[int] | None = None,
79
+ defer_scope_to_caller: bool = False,
80
+ ) -> list[str]:
81
+ """Flag functions whose executable span exceeds cognitive-load thresholds.
82
+
83
+ Function executable spans — the definition span (signature line through
84
+ last body statement, inclusive) minus the leading docstring lines of the
85
+ function and of every function or class nested within it, per
86
+ ``_definition_docstring_line_span`` summed over the nested definitions —
87
+ at or above ``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` appear in
88
+ the returned issues list and block the write at the
89
+ gate. The threshold rests on the small-function guidance in Robert C.
90
+ Martin, *Clean Code* Chapter Three ("Functions") and the Google Python Style
91
+ Guide's ~forty-line function review hint
92
+ (https://google.github.io/styleguide/pyguide.html) — a measure of
93
+ executable complexity, paired with the Guide's complete-docstring mandate
94
+ for public APIs, so documentation lines never count against the gate; this
95
+ gate blocks on body growth that pushes a function past that span. It does
96
+ not derive from CODE_RULES file-length guidance, which governs advisory
97
+ file-length signals and argues against hard numeric blocks.
98
+
99
+ The issue message carries ``Function NAME (defined at line X) is Y lines``
100
+ precisely so the gate's ``function_length_span_range`` can recover the
101
+ function's full declared span (lines ``X`` through ``X + Y - 1``). The
102
+ gate classifies the violation blocking when that span intersects the
103
+ diff's added lines — the body grew this diff — and advisory otherwise — a
104
+ pre-existing, untouched long function in a file the diff happened to
105
+ touch. Anchoring to the span rather than a single ``Line N:`` definition
106
+ line lets body growth on any interior line block correctly even when the
107
+ ``def`` line itself is untouched.
108
+
109
+ Exempt: test files (test bodies are sometimes long by necessity), Django
110
+ migrations (auto-generated), workflow registries (registry entries), and
111
+ hook infrastructure.
112
+
113
+ Args:
114
+ content: The Python source to analyze.
115
+ file_path: The path of the file being checked.
116
+ all_changed_lines: Post-edit line numbers the current edit touched, or
117
+ None to treat the whole file as in scope. When provided, a violation
118
+ blocks only when the function's declared span intersects the changed
119
+ lines.
120
+ defer_scope_to_caller: When True, return every violation so the
121
+ commit/push gate's ``split_violations_by_scope`` can scope by added
122
+ line and report the in-scope set.
123
+
124
+ Returns:
125
+ Blocking issues. When *defer_scope_to_caller* is True every violation is
126
+ returned for the gate to scope; otherwise every violation in scope is
127
+ returned.
128
+ """
129
+ if is_test_file(file_path):
130
+ return []
131
+ if is_hook_infrastructure(file_path):
132
+ return []
133
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
134
+ return []
135
+
136
+ try:
137
+ parsed_tree = ast.parse(content)
138
+ except SyntaxError:
139
+ return []
140
+
141
+ all_violations_in_walk_order: list[tuple[range, str]] = []
142
+ for each_node in ast.walk(parsed_tree):
143
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
144
+ continue
145
+ line_span = _function_definition_line_span(each_node)
146
+ if line_span < FUNCTION_LENGTH_BLOCKING_THRESHOLD:
147
+ continue
148
+ docstring_line_total = sum(
149
+ _definition_docstring_line_span(each_definition)
150
+ for each_definition in ast.walk(each_node)
151
+ if isinstance(
152
+ each_definition, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
153
+ )
154
+ )
155
+ executable_line_span = line_span - docstring_line_total
156
+ if executable_line_span >= FUNCTION_LENGTH_BLOCKING_THRESHOLD:
157
+ span_range = range(each_node.lineno, each_node.lineno + line_span)
158
+ message = (
159
+ f"Function {each_node.name!r} (defined at line {each_node.lineno}) "
160
+ f"is {line_span} lines - {FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX}"
161
+ )
162
+ all_violations_in_walk_order.append((span_range, message))
163
+ return _scope_violations_to_changed_lines(
164
+ all_violations_in_walk_order,
165
+ all_changed_lines,
166
+ defer_scope_to_caller,
167
+ )
@@ -0,0 +1,385 @@
1
+ """Banned identifier, banned noun-word, and banned function-prefix naming 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_path_utils import ( # noqa: E402
15
+ is_config_file,
16
+ )
17
+ from code_rules_shared import ( # noqa: E402
18
+ _collect_annotated_arguments,
19
+ _collect_target_names,
20
+ _scope_violations_to_changed_lines,
21
+ _walk_skipping_type_checking_blocks,
22
+ is_hook_infrastructure,
23
+ is_migration_file,
24
+ is_test_file,
25
+ is_workflow_registry_file,
26
+ )
27
+
28
+ from hooks_constants.banned_identifiers_constants import ( # noqa: E402
29
+ ALL_BANNED_IDENTIFIERS,
30
+ ALL_BANNED_NOUN_WORDS,
31
+ BANNED_IDENTIFIER_MESSAGE_SUFFIX,
32
+ BANNED_IDENTIFIER_SKIP_ADVISORY,
33
+ BANNED_NOUN_WORD_MESSAGE_SUFFIX,
34
+ CAMEL_CASE_WORD_PATTERN,
35
+ MAX_BANNED_IDENTIFIER_ISSUES,
36
+ )
37
+ from hooks_constants.blocking_check_limits import ( # noqa: E402
38
+ ALL_BANNED_PREFIX_NAMES,
39
+ MAX_BANNED_PREFIX_ISSUES,
40
+ )
41
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
42
+ ALL_SELF_AND_CLS_PARAMETER_NAMES,
43
+ BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE,
44
+ )
45
+
46
+
47
+ def _collect_banned_names_from_target(target: ast.expr) -> list[ast.Name]:
48
+ """Return every banned ast.Name reachable through tuple/list unpacking or starred targets."""
49
+ return [
50
+ each_name_node
51
+ for each_name_node in _collect_target_names(target)
52
+ if each_name_node.id in ALL_BANNED_IDENTIFIERS
53
+ ]
54
+
55
+
56
+ def _value_is_parse_args_namespace_call(value_node: ast.AST | None) -> bool:
57
+ if value_node is None:
58
+ return False
59
+ if not isinstance(value_node, ast.Call):
60
+ return False
61
+ callee = value_node.func
62
+ return isinstance(callee, ast.Attribute) and callee.attr == "parse_args"
63
+
64
+
65
+ def _without_parse_args_namespace_exemption(
66
+ all_banned_names: list[ast.Name], value_node: ast.AST | None
67
+ ) -> list[ast.Name]:
68
+ if not _value_is_parse_args_namespace_call(value_node):
69
+ return all_banned_names
70
+ return [each_name for each_name in all_banned_names if each_name.id != "args"]
71
+
72
+
73
+ def _synthesize_alias_name_node(
74
+ bound_identifier: str, alias_node: ast.alias
75
+ ) -> ast.Name:
76
+ synthetic_name = ast.Name(id=bound_identifier, ctx=ast.Store())
77
+ synthetic_name.lineno = alias_node.lineno
78
+ synthetic_name.col_offset = alias_node.col_offset
79
+ return synthetic_name
80
+
81
+
82
+ def _collect_banned_names_from_import(
83
+ import_statement: ast.Import | ast.ImportFrom,
84
+ ) -> list[ast.Name]:
85
+ banned_alias_nodes: list[ast.Name] = []
86
+ for each_alias in import_statement.names:
87
+ bound_identifier = each_alias.asname or each_alias.name.split(".")[0]
88
+ if bound_identifier in ALL_BANNED_IDENTIFIERS:
89
+ banned_alias_nodes.append(
90
+ _synthesize_alias_name_node(bound_identifier, each_alias)
91
+ )
92
+ return banned_alias_nodes
93
+
94
+
95
+ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
96
+ """Return banned ast.Name nodes introduced by a single binding construct."""
97
+ if isinstance(node, ast.Assign):
98
+ banned_names: list[ast.Name] = []
99
+ for each_target in node.targets:
100
+ banned_names.extend(_collect_banned_names_from_target(each_target))
101
+ return _without_parse_args_namespace_exemption(banned_names, node.value)
102
+ if isinstance(node, ast.AnnAssign):
103
+ banned_names = _collect_banned_names_from_target(node.target)
104
+ return _without_parse_args_namespace_exemption(banned_names, node.value)
105
+ if isinstance(node, (ast.For, ast.AsyncFor)):
106
+ return _collect_banned_names_from_target(node.target)
107
+ if isinstance(node, ast.comprehension):
108
+ return _collect_banned_names_from_target(node.target)
109
+ if isinstance(node, ast.withitem):
110
+ if node.optional_vars is None:
111
+ return []
112
+ return _collect_banned_names_from_target(node.optional_vars)
113
+ if isinstance(node, ast.NamedExpr):
114
+ banned_names = _collect_banned_names_from_target(node.target)
115
+ return _without_parse_args_namespace_exemption(banned_names, node.value)
116
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
117
+ return _collect_banned_names_from_import(node)
118
+ return []
119
+
120
+
121
+ def check_banned_identifiers(content: str, file_path: str) -> list[str]:
122
+ """Flag assignments to identifiers banned by the project Naming rules."""
123
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
124
+ return []
125
+
126
+ try:
127
+ parsed_tree = ast.parse(content)
128
+ except SyntaxError:
129
+ print(f"{file_path}: {BANNED_IDENTIFIER_SKIP_ADVISORY}", file=sys.stderr)
130
+ return []
131
+
132
+ banned_name_nodes: list[ast.Name] = []
133
+ for each_node in ast.walk(parsed_tree):
134
+ banned_name_nodes.extend(_collect_banned_names_from_node(each_node))
135
+
136
+ banned_name_nodes.sort(key=lambda each_name: (each_name.lineno, each_name.col_offset))
137
+
138
+ issues: list[str] = []
139
+ for each_name in banned_name_nodes:
140
+ issues.append(
141
+ f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - {BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
142
+ )
143
+ if len(issues) >= MAX_BANNED_IDENTIFIER_ISSUES:
144
+ break
145
+
146
+ return issues
147
+
148
+
149
+ def _identifier_word_parts(identifier: str) -> list[str]:
150
+ """Split an identifier into lowercase word parts.
151
+
152
+ Handles snake_case (split on ``_``), SCREAMING_SNAKE_CASE, and camelCase /
153
+ PascalCase (split on capital-letter boundaries). Returns a list of
154
+ lowercased word tokens for membership comparison against banned-noun
155
+ vocabularies.
156
+
157
+ Args:
158
+ identifier: A Python identifier (variable, parameter, class, or
159
+ function name).
160
+
161
+ Returns:
162
+ Word tokens in their original order, lowercased. Empty list when the
163
+ identifier carries no letter characters.
164
+ """
165
+ all_words: list[str] = []
166
+ for each_snake_segment in identifier.split("_"):
167
+ if not each_snake_segment:
168
+ continue
169
+ camel_pieces = CAMEL_CASE_WORD_PATTERN.findall(each_snake_segment)
170
+ if camel_pieces:
171
+ for each_piece in camel_pieces:
172
+ all_words.append(each_piece.lower())
173
+ else:
174
+ all_words.append(each_snake_segment.lower())
175
+ return all_words
176
+
177
+
178
+ def _find_banned_noun_word(identifier: str) -> str | None:
179
+ """Return the first banned-noun word embedded in *identifier*, or None.
180
+
181
+ Args:
182
+ identifier: A Python identifier.
183
+
184
+ Returns:
185
+ The lowercased banned noun word that appears as a word part inside the
186
+ identifier (e.g., ``'result'`` for ``'HolidayPeakResult'``). Returns
187
+ ``None`` when no banned noun word is present.
188
+ """
189
+ for each_word in _identifier_word_parts(identifier):
190
+ if each_word in ALL_BANNED_NOUN_WORDS:
191
+ return each_word
192
+ return None
193
+
194
+
195
+ def _is_dunder_name(identifier: str) -> bool:
196
+ return identifier.startswith("__") and identifier.endswith("__")
197
+
198
+
199
+ def _collect_banned_noun_word_bindings(
200
+ parsed_tree: ast.AST,
201
+ ) -> list[tuple[str, int, int, str]]:
202
+ """Yield ``(identifier, lineno, col_offset, banned_word)`` for each binding.
203
+
204
+ Walks assignment targets, annotated assignments, function/method
205
+ parameters, function/method definitions, and class definitions. Skips
206
+ identifiers that already match ``ALL_BANNED_IDENTIFIERS`` exactly (those
207
+ are reported by ``check_banned_identifiers``) and dunder names.
208
+ """
209
+ flagged_bindings: list[tuple[str, int, int, str]] = []
210
+ seen_keys: set[tuple[str, int, int]] = set()
211
+
212
+ def record(name: str, lineno: int, col_offset: int) -> None:
213
+ if name in ALL_BANNED_IDENTIFIERS:
214
+ return
215
+ if _is_dunder_name(name):
216
+ return
217
+ banned_word = _find_banned_noun_word(name)
218
+ if banned_word is None:
219
+ return
220
+ key = (name, lineno, col_offset)
221
+ if key in seen_keys:
222
+ return
223
+ seen_keys.add(key)
224
+ flagged_bindings.append((name, lineno, col_offset, banned_word))
225
+
226
+ for each_node in ast.walk(parsed_tree):
227
+ if isinstance(each_node, ast.Assign):
228
+ for each_target in each_node.targets:
229
+ for each_name_node in _collect_target_names(each_target):
230
+ record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
231
+ elif isinstance(each_node, ast.AnnAssign):
232
+ for each_name_node in _collect_target_names(each_node.target):
233
+ record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
234
+ elif isinstance(each_node, (ast.For, ast.AsyncFor)):
235
+ for each_name_node in _collect_target_names(each_node.target):
236
+ record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
237
+ elif isinstance(each_node, ast.NamedExpr) and isinstance(each_node.target, ast.Name):
238
+ record(each_node.target.id, each_node.target.lineno, each_node.target.col_offset)
239
+ elif isinstance(each_node, ast.comprehension):
240
+ for each_name_node in _collect_target_names(each_node.target):
241
+ record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
242
+ elif isinstance(each_node, ast.withitem) and each_node.optional_vars is not None:
243
+ for each_name_node in _collect_target_names(each_node.optional_vars):
244
+ record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
245
+ elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
246
+ record(each_node.name, each_node.lineno, each_node.col_offset)
247
+ for each_arg in _collect_annotated_arguments(each_node):
248
+ if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
249
+ continue
250
+ record(each_arg.arg, each_arg.lineno, each_arg.col_offset)
251
+ elif isinstance(each_node, ast.ClassDef):
252
+ record(each_node.name, each_node.lineno, each_node.col_offset)
253
+ elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
254
+ for each_alias in each_node.names:
255
+ if each_alias.asname is None:
256
+ continue
257
+ record(each_alias.asname, each_node.lineno, each_node.col_offset)
258
+
259
+ flagged_bindings.sort(key=lambda binding: (binding[1], binding[2]))
260
+ return flagged_bindings
261
+
262
+
263
+ def check_banned_noun_word_boundary(
264
+ content: str,
265
+ file_path: str,
266
+ all_changed_lines: set[int] | None = None,
267
+ defer_scope_to_caller: bool = False,
268
+ ) -> list[str]:
269
+ """Flag identifiers containing CODE_RULES naming-rule banned noun words.
270
+
271
+ Companion to ``check_banned_identifiers`` (exact-match cases only). This
272
+ check catches the wider pattern: a banned noun word from
273
+ ``ALL_BANNED_NOUN_WORDS`` — the singular nouns ``result``, ``data``,
274
+ ``output``, ``response``, ``value``, ``item``, ``temp`` plus the plural
275
+ forms ``results``, ``outputs``, ``responses``, ``values``, ``items`` —
276
+ appearing as a snake_case word part or camelCase word part inside a longer
277
+ identifier (``canned_results``, ``HolidayPeakResult``, ``OUTPUT_DIR``,
278
+ ``cached_response``).
279
+
280
+ Skips test files, config files, hook infrastructure, workflow registries,
281
+ and migrations. Identifiers that exactly match ``ALL_BANNED_IDENTIFIERS``
282
+ are skipped because they are already reported by
283
+ ``check_banned_identifiers``.
284
+
285
+ Scoping mirrors ``check_function_length`` and
286
+ ``check_tests_use_isolated_filesystem_paths`` through the shared
287
+ ``_scope_violations_to_changed_lines`` helper. A banned-noun binding is a
288
+ point fact about one identifier, so its enclosing unit is its own binding
289
+ line: each violation carries the binding line as a one-line ``range`` for
290
+ terminal diff scoping and a ``(binding span at line X, spanning 1 lines)``
291
+ message fragment the commit gate reconstructs through the same shared span
292
+ extractor registry the other two scoped checks use. Anchoring to the
293
+ binding line (rather than the whole enclosing function) matches the
294
+ companion exact-match ``check_banned_identifiers`` and keeps a pre-existing
295
+ binding out of scope when an unrelated line of its enclosing function is
296
+ edited. On a terminal Edit only violations whose binding line is among
297
+ ``all_changed_lines`` are returned; on a new-file or full-file write every
298
+ violation is in scope; ``defer_scope_to_caller`` returns every violation so
299
+ the gate scopes by added line.
300
+
301
+ Args:
302
+ content: The reconstructed effective file content to analyze (the
303
+ whole post-edit file on an Edit, the whole file at the gate).
304
+ file_path: The path of the file being checked (used for exemption
305
+ routing).
306
+ all_changed_lines: Post-edit line numbers the current edit touched, or
307
+ None to treat the whole file as in scope. When provided, a violation
308
+ blocks only when its binding line is among the changed lines.
309
+ defer_scope_to_caller: When True, return every violation so the
310
+ commit/push gate's ``split_violations_by_scope`` can scope by added
311
+ line and report the in-scope set.
312
+
313
+ Returns:
314
+ Issue strings, each describing one offending binding. When
315
+ *defer_scope_to_caller* is True every binding is returned for the gate
316
+ to scope; otherwise every binding in scope is returned.
317
+ """
318
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
319
+ return []
320
+ if is_config_file(file_path):
321
+ return []
322
+ if is_workflow_registry_file(file_path):
323
+ return []
324
+ if is_migration_file(file_path):
325
+ return []
326
+
327
+ try:
328
+ parsed_tree = ast.parse(content)
329
+ except SyntaxError:
330
+ return []
331
+
332
+ single_line_span = 1
333
+ all_violations_in_walk_order: list[tuple[range, str]] = []
334
+ for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(parsed_tree):
335
+ span_range = range(each_lineno, each_lineno + single_line_span)
336
+ span_fragment = BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE.format(
337
+ definition_line=each_lineno, line_span=single_line_span
338
+ )
339
+ message = (
340
+ f"Line {each_lineno}: Identifier {each_name!r} {BANNED_NOUN_WORD_MESSAGE_SUFFIX} "
341
+ f"(word: {each_word!r}) {span_fragment}"
342
+ )
343
+ all_violations_in_walk_order.append((span_range, message))
344
+ return _scope_violations_to_changed_lines(
345
+ all_violations_in_walk_order,
346
+ all_changed_lines,
347
+ defer_scope_to_caller,
348
+ )
349
+
350
+
351
+ def check_banned_prefixes(content: str, file_path: str) -> list[str]:
352
+ """Flag function and method names using generic banned prefixes.
353
+
354
+ Per CODE_RULES.md / AGENTS.md Naming, function names use specific verbs.
355
+ Generic prefixes ``handle_``, ``process_``, ``manage_``, ``do_`` are
356
+ placeholders that hide the actual responsibility and are flagged so the
357
+ author renames the function to a specific verb.
358
+ """
359
+ if is_test_file(file_path) or is_hook_infrastructure(file_path) or is_config_file(file_path):
360
+ return []
361
+
362
+ try:
363
+ parsed_tree = ast.parse(content)
364
+ except SyntaxError:
365
+ return []
366
+
367
+ flagged_function_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
368
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
369
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
370
+ continue
371
+ if any(each_node.name.startswith(each_prefix) for each_prefix in ALL_BANNED_PREFIX_NAMES):
372
+ flagged_function_nodes.append(each_node)
373
+
374
+ flagged_function_nodes.sort(key=lambda each_function: each_function.lineno)
375
+
376
+ issues: list[str] = []
377
+ for each_function in flagged_function_nodes:
378
+ issues.append(
379
+ f"Line {each_function.lineno}: Function '{each_function.name}' uses banned prefix - "
380
+ "rename to a specific verb (see CODE_RULES Naming section)"
381
+ )
382
+ if len(issues) >= MAX_BANNED_PREFIX_ISSUES:
383
+ break
384
+
385
+ return issues