claude-dev-env 1.50.1 → 1.50.3

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 (91) hide show
  1. package/_shared/pr-loop/audit-contract.md +3 -3
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +3 -2
  3. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  6. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  7. package/audit-rubrics/prompts/category-e-dead-code.md +5 -4
  8. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  9. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  10. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  11. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  12. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  13. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  14. package/docs/CODE_RULES.md +1 -1
  15. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  16. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  17. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  18. package/hooks/blocking/code_rules_comments.py +337 -0
  19. package/hooks/blocking/code_rules_constants_config.py +252 -0
  20. package/hooks/blocking/code_rules_docstrings.py +308 -0
  21. package/hooks/blocking/code_rules_enforcer.py +98 -5807
  22. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  23. package/hooks/blocking/code_rules_magic_values.py +180 -0
  24. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  25. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  26. package/hooks/blocking/code_rules_optional_params.py +288 -0
  27. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  28. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  29. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  30. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  31. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  32. package/hooks/blocking/code_rules_shared.py +301 -0
  33. package/hooks/blocking/code_rules_string_magic.py +207 -0
  34. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  35. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  36. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  37. package/hooks/blocking/code_rules_type_escape.py +341 -0
  38. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  39. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  40. package/hooks/blocking/tdd_enforcer.py +31 -0
  41. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  42. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  44. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  45. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  46. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  47. package/hooks/blocking/test_code_rules_enforcer_function_length.py +18 -13
  48. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  49. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  50. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  64. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  65. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  66. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  67. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  68. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  69. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  70. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  71. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  72. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  73. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  74. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  75. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  76. package/package.json +1 -1
  77. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +13 -7
  78. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +21 -11
  79. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +92 -0
  80. package/skills/bugteam/CONSTRAINTS.md +1 -1
  81. package/skills/bugteam/PROMPTS.md +20 -48
  82. package/skills/bugteam/SKILL.md +5 -5
  83. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  84. package/skills/bugteam/reference/audit-contract.md +4 -4
  85. package/skills/bugteam/reference/design-rationale.md +1 -1
  86. package/skills/findbugs/SKILL.md +21 -12
  87. package/skills/fixbugs/SKILL.md +1 -1
  88. package/skills/qbug/SKILL.md +5 -5
  89. package/skills/qbug/test_qbug_skill_audit_schema.py +13 -23
  90. package/skills/refine/SKILL.md +1 -1
  91. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
@@ -0,0 +1,252 @@
1
+ """Constants-outside-config checks and the file-global constant use-count check."""
2
+
3
+ import ast
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
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 code_rules_path_utils import ( # noqa: E402
17
+ is_config_file,
18
+ )
19
+ from code_rules_shared import ( # noqa: E402
20
+ _build_parent_map,
21
+ get_file_extension,
22
+ is_migration_file,
23
+ is_test_file,
24
+ is_workflow_registry_file,
25
+ )
26
+
27
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
28
+ ALL_PYTHON_EXTENSIONS,
29
+ FILE_GLOBAL_UPPER_SNAKE_PATTERN,
30
+ )
31
+
32
+
33
+ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
34
+ """Check for UPPER_SNAKE constants defined outside config files."""
35
+ if is_config_file(file_path):
36
+ return []
37
+
38
+ if is_test_file(file_path):
39
+ return []
40
+
41
+ if is_workflow_registry_file(file_path):
42
+ return []
43
+
44
+ if is_migration_file(file_path):
45
+ return []
46
+
47
+ issues = []
48
+ lines = content.split("\n")
49
+ is_inside_function = False
50
+ is_inside_class = False
51
+
52
+ constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
53
+
54
+ for each_line_number, each_line in enumerate(lines, 1):
55
+ stripped = each_line.strip()
56
+
57
+ if not stripped:
58
+ continue
59
+
60
+ if re.match(r"^(async\s+)?def\s+\w+", stripped):
61
+ is_inside_function = True
62
+ continue
63
+
64
+ if re.match(r"^class\s+\w+", stripped):
65
+ is_inside_class = True
66
+ is_inside_function = False
67
+ continue
68
+
69
+ indent = len(each_line) - len(each_line.lstrip())
70
+ if indent == 0 and stripped and not stripped.startswith(("#", "@", ")")):
71
+ is_inside_function = False
72
+ is_inside_class = False
73
+
74
+ if not is_inside_function and not is_inside_class:
75
+ match = constant_pattern.match(stripped)
76
+ if match:
77
+ constant_name = match.group(1)
78
+ if constant_name not in ("__all__",):
79
+ issues.append(f"Line {each_line_number}: Constant {constant_name} - move to config/")
80
+
81
+ return issues
82
+
83
+
84
+ def _is_exempt_for_advisory_scan(file_path: str) -> bool:
85
+ """Return True when the file is exempt from the function-local UPPER_SNAKE advisory."""
86
+ if is_config_file(file_path):
87
+ return True
88
+ if is_test_file(file_path):
89
+ return True
90
+ if is_workflow_registry_file(file_path):
91
+ return True
92
+ if is_migration_file(file_path):
93
+ return True
94
+ return False
95
+
96
+
97
+ def _scan_function_body_constants(content: str) -> list[str]:
98
+ """Return advisory messages for UPPER_SNAKE assignments inside function bodies.
99
+
100
+ Only lines inside a function body (tracked via an indent stack) are
101
+ flagged. Module-level assignments and class-body assignments are ignored.
102
+ """
103
+ advisory_issues: list[str] = []
104
+ lines = content.split("\n")
105
+ function_indent_stack: list[int] = []
106
+ constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})(?:\s*:\s*[^=]+)?\s*=\s*[^=]")
107
+
108
+ for each_line_number, each_line in enumerate(lines, 1):
109
+ stripped = each_line.strip()
110
+
111
+ if not stripped:
112
+ continue
113
+
114
+ indent = len(each_line) - len(each_line.lstrip())
115
+
116
+ while function_indent_stack and indent <= function_indent_stack[-1] and not stripped.startswith(("#", "@", ")")):
117
+ function_indent_stack.pop()
118
+
119
+ if re.match(r"^class\s+\w+", stripped):
120
+ if indent == 0:
121
+ function_indent_stack.clear()
122
+ continue
123
+
124
+ if re.match(r"^(async\s+)?def\s+\w+", stripped):
125
+ function_indent_stack.append(indent)
126
+ continue
127
+
128
+ if function_indent_stack:
129
+ match = constant_pattern.match(stripped)
130
+ if match:
131
+ constant_name = match.group(1)
132
+ advisory_issues.append(
133
+ f"Line {each_line_number}: Function-local constant {constant_name} - consider moving to config/"
134
+ )
135
+
136
+ return advisory_issues
137
+
138
+
139
+ def check_constants_outside_config_advisory(content: str, file_path: str) -> list[str]:
140
+ """Return advisory entries for UPPER_SNAKE assignments inside function bodies.
141
+
142
+ Module-level UPPER_SNAKE outside config/ is blocking (see
143
+ check_constants_outside_config). Function-local UPPER_SNAKE is a softer
144
+ smell — it belongs in config/ but does not block the write. This function
145
+ surfaces those as advisory so callers can route them to stderr rather than
146
+ to the blocking deny payload.
147
+ """
148
+ if _is_exempt_for_advisory_scan(file_path):
149
+ return []
150
+ return _scan_function_body_constants(content)
151
+
152
+
153
+ def _is_upper_snake_constant_name(name: str) -> bool:
154
+ """Return True for UPPER_SNAKE identifiers including those with a leading underscore."""
155
+ return bool(FILE_GLOBAL_UPPER_SNAKE_PATTERN.match(name))
156
+
157
+
158
+ def _collect_module_level_upper_snake_constants(
159
+ module_tree: ast.Module,
160
+ ) -> dict[str, int]:
161
+ """Return mapping of module-level UPPER_SNAKE constant name to its line number."""
162
+ constants_by_name: dict[str, int] = {}
163
+ for each_node in module_tree.body:
164
+ if isinstance(each_node, ast.Assign):
165
+ for each_target in each_node.targets:
166
+ if isinstance(each_target, ast.Name) and _is_upper_snake_constant_name(each_target.id):
167
+ constants_by_name.setdefault(each_target.id, each_node.lineno)
168
+ elif isinstance(each_node, ast.AnnAssign):
169
+ if isinstance(each_node.target, ast.Name) and _is_upper_snake_constant_name(each_node.target.id):
170
+ constants_by_name.setdefault(each_node.target.id, each_node.lineno)
171
+ return constants_by_name
172
+
173
+
174
+ def _resolve_enclosing_function_qname(
175
+ load_node: ast.Name,
176
+ parent_by_child_id: dict[int, ast.AST],
177
+ ) -> Optional[str]:
178
+ """Return 'ClassName.function_name' or 'function_name' for the enclosing function.
179
+
180
+ Returns None when the reference is at module scope (no enclosing function).
181
+ Decorator expressions on a function/method count as belonging to that function.
182
+ """
183
+ enclosing_function_name: Optional[str] = None
184
+ enclosing_class_name: Optional[str] = None
185
+ current_ancestor = parent_by_child_id.get(id(load_node))
186
+ while current_ancestor is not None:
187
+ if isinstance(current_ancestor, (ast.FunctionDef, ast.AsyncFunctionDef)) and enclosing_function_name is None:
188
+ enclosing_function_name = current_ancestor.name
189
+ elif isinstance(current_ancestor, ast.ClassDef):
190
+ enclosing_class_name = current_ancestor.name
191
+ break
192
+ current_ancestor = parent_by_child_id.get(id(current_ancestor))
193
+ if enclosing_function_name is None:
194
+ if enclosing_class_name is not None:
195
+ return f"<class:{enclosing_class_name}>"
196
+ return None
197
+ if enclosing_class_name is not None:
198
+ return f"{enclosing_class_name}.{enclosing_function_name}"
199
+ return enclosing_function_name
200
+
201
+
202
+ def check_file_global_constants_use_count(content: str, file_path: str) -> list[str]:
203
+ """Flag module-level UPPER_SNAKE constants referenced by only one function/method.
204
+
205
+ Enforces the file-global-constants use-count rule: a constant used by just
206
+ one caller belongs in that caller's scope. Test files, config files, and
207
+ non-Python files are exempt. Constants with zero references are out of
208
+ scope. The enforcer entry module (``hooks/blocking/code_rules_enforcer.py``)
209
+ is exempt to avoid self-blocking.
210
+ """
211
+ if is_test_file(file_path):
212
+ return []
213
+ if is_config_file(file_path):
214
+ return []
215
+ if get_file_extension(file_path) not in ALL_PYTHON_EXTENSIONS:
216
+ return []
217
+ if file_path.replace("\\", "/").endswith("hooks/blocking/code_rules_enforcer.py"):
218
+ return []
219
+
220
+ try:
221
+ module_tree = ast.parse(content)
222
+ except SyntaxError:
223
+ return []
224
+
225
+ constants_by_name = _collect_module_level_upper_snake_constants(module_tree)
226
+ if not constants_by_name:
227
+ return []
228
+
229
+ parent_by_child_id = _build_parent_map(module_tree)
230
+ callers_by_constant: dict[str, set[str]] = {name: set() for name in constants_by_name}
231
+ for each_node in ast.walk(module_tree):
232
+ if not isinstance(each_node, ast.Name):
233
+ continue
234
+ if not isinstance(each_node.ctx, ast.Load):
235
+ continue
236
+ if each_node.id not in callers_by_constant:
237
+ continue
238
+ enclosing_qname = _resolve_enclosing_function_qname(each_node, parent_by_child_id)
239
+ if enclosing_qname is None:
240
+ callers_by_constant[each_node.id].add("<module-scope>")
241
+ else:
242
+ callers_by_constant[each_node.id].add(enclosing_qname)
243
+
244
+ issues: list[str] = []
245
+ for each_constant_name, each_line_number in sorted(constants_by_name.items(), key=lambda pair: pair[1]):
246
+ caller_count = len(callers_by_constant[each_constant_name])
247
+ if caller_count == 1:
248
+ issues.append(
249
+ f"Line {each_line_number}: File-global constant {each_constant_name} used by only 1 function/method - move to method scope or add a second caller"
250
+ )
251
+
252
+ return issues
@@ -0,0 +1,308 @@
1
+ """Google-style docstring presence and docstring Args-versus-signature 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
+ _statement_is_docstring,
16
+ _walk_skipping_nested_functions,
17
+ _walk_skipping_type_checking_blocks,
18
+ is_hook_infrastructure,
19
+ is_test_file,
20
+ )
21
+
22
+ from hooks_constants.blocking_check_limits import ( # noqa: E402
23
+ ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
24
+ ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
25
+ DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
26
+ MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
27
+ MAX_DOCSTRING_FORMAT_ISSUES,
28
+ )
29
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
30
+ ALL_DOCSTRING_ARGS_SECTION_HEADERS,
31
+ ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
32
+ ALL_SELF_AND_CLS_PARAMETER_NAMES,
33
+ DOCSTRING_ARG_ENTRY_PATTERN,
34
+ )
35
+
36
+
37
+ def _function_is_private_or_dunder(function_name: str) -> bool:
38
+ if function_name.startswith("__") and function_name.endswith("__"):
39
+ return True
40
+ return function_name.startswith("_")
41
+
42
+
43
+ def _decorator_label(decorator_node: ast.expr) -> str:
44
+ if isinstance(decorator_node, ast.Name):
45
+ return decorator_node.id
46
+ if isinstance(decorator_node, ast.Attribute):
47
+ prefix = (
48
+ decorator_node.value.id
49
+ if isinstance(decorator_node.value, ast.Name)
50
+ else ""
51
+ )
52
+ return f"{prefix}.{decorator_node.attr}" if prefix else decorator_node.attr
53
+ if isinstance(decorator_node, ast.Call):
54
+ return _decorator_label(decorator_node.func)
55
+ return ""
56
+
57
+
58
+ def _function_has_exempt_decorator(
59
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
60
+ ) -> bool:
61
+ for each_decorator in function_node.decorator_list:
62
+ if _decorator_label(each_decorator) in ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES:
63
+ return True
64
+ return False
65
+
66
+
67
+ def _function_body_line_count(
68
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
69
+ ) -> int:
70
+ if not function_node.body:
71
+ return 0
72
+ first_body_index = 0
73
+ if _statement_is_docstring(function_node.body[0]):
74
+ if len(function_node.body) == 1:
75
+ return 0
76
+ first_body_index = 1
77
+ last_statement = function_node.body[-1]
78
+ end_line = getattr(last_statement, "end_lineno", last_statement.lineno)
79
+ first_line = function_node.body[first_body_index].lineno
80
+ return max(0, end_line - first_line + 1)
81
+
82
+
83
+ def _function_documentable_parameter_count(
84
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
85
+ ) -> int:
86
+ documentable_count = 0
87
+ for each_argument in function_node.args.args:
88
+ if each_argument.arg in ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES:
89
+ continue
90
+ documentable_count += 1
91
+ documentable_count += len(function_node.args.kwonlyargs)
92
+ for each_argument in function_node.args.posonlyargs:
93
+ if each_argument.arg in ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES:
94
+ continue
95
+ documentable_count += 1
96
+ if function_node.args.vararg is not None:
97
+ documentable_count += 1
98
+ if function_node.args.kwarg is not None:
99
+ documentable_count += 1
100
+ return documentable_count
101
+
102
+
103
+ def _annotation_is_explicit_none_return(annotation_node: ast.expr | None) -> bool:
104
+ if annotation_node is None:
105
+ return False
106
+ if isinstance(annotation_node, ast.Constant) and annotation_node.value is None:
107
+ return True
108
+ return isinstance(annotation_node, ast.Name) and annotation_node.id == "None"
109
+
110
+
111
+ def _annotation_is_noreturn(annotation_node: ast.expr | None) -> bool:
112
+ if annotation_node is None:
113
+ return False
114
+ if isinstance(annotation_node, ast.Name) and annotation_node.id == "NoReturn":
115
+ return True
116
+ return isinstance(annotation_node, ast.Attribute) and annotation_node.attr == "NoReturn"
117
+
118
+
119
+ def _function_body_contains_raise(
120
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
121
+ ) -> bool:
122
+ return any(
123
+ isinstance(each_descendant, ast.Raise)
124
+ for each_descendant in _walk_skipping_nested_functions(function_node)
125
+ )
126
+
127
+
128
+ def _function_body_contains_yield(
129
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
130
+ ) -> bool:
131
+ return any(
132
+ isinstance(each_descendant, (ast.Yield, ast.YieldFrom))
133
+ for each_descendant in _walk_skipping_nested_functions(function_node)
134
+ )
135
+
136
+
137
+ def _function_docstring_text(
138
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
139
+ ) -> str:
140
+ docstring_value = ast.get_docstring(function_node)
141
+ return docstring_value or ""
142
+
143
+
144
+ def _missing_docstring_sections(
145
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
146
+ ) -> list[str]:
147
+ docstring_text = _function_docstring_text(function_node)
148
+ documentable_parameter_count = _function_documentable_parameter_count(function_node)
149
+ has_non_none_return = (
150
+ function_node.returns is not None
151
+ and not _annotation_is_explicit_none_return(function_node.returns)
152
+ and not _annotation_is_noreturn(function_node.returns)
153
+ )
154
+ has_raise_statement = _function_body_contains_raise(function_node)
155
+ has_yield_statement = _function_body_contains_yield(function_node)
156
+ missing_sections: list[str] = []
157
+ if documentable_parameter_count > 0 and "Args:" not in docstring_text:
158
+ missing_sections.append("Args:")
159
+ if has_non_none_return and not (
160
+ "Returns:" in docstring_text or "Yields:" in docstring_text
161
+ ):
162
+ section_label = "Yields:" if has_yield_statement else "Returns:"
163
+ missing_sections.append(section_label)
164
+ if has_raise_statement and "Raises:" not in docstring_text:
165
+ missing_sections.append("Raises:")
166
+ return missing_sections
167
+
168
+
169
+ def check_docstring_format(content: str, file_path: str) -> list[str]:
170
+ """Flag public functions missing required Google-style docstring sections.
171
+
172
+ A public function whose signature has documentable parameters, returns
173
+ a non-None value, or raises must have the matching `Args:` / `Returns:`
174
+ (or `Yields:`) / `Raises:` sections so callers can read the contract
175
+ without scanning the body.
176
+ """
177
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
178
+ return []
179
+
180
+ try:
181
+ parsed_tree = ast.parse(content)
182
+ except SyntaxError:
183
+ return []
184
+
185
+ issues: list[str] = []
186
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
187
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
188
+ continue
189
+ if _function_is_private_or_dunder(each_node.name):
190
+ continue
191
+ if _function_has_exempt_decorator(each_node):
192
+ continue
193
+ if _function_body_line_count(each_node) <= DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT:
194
+ continue
195
+ missing_sections = _missing_docstring_sections(each_node)
196
+ if not missing_sections:
197
+ continue
198
+ issues.append(
199
+ f"Line {each_node.lineno}: {each_node.name}() docstring missing required "
200
+ f"section(s): {', '.join(missing_sections)} — Google style required for public APIs"
201
+ )
202
+ if len(issues) >= MAX_DOCSTRING_FORMAT_ISSUES:
203
+ break
204
+ return issues[:MAX_DOCSTRING_FORMAT_ISSUES]
205
+
206
+
207
+ def _signature_parameter_names(
208
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
209
+ ) -> set[str]:
210
+ arguments = function_node.args
211
+ real_names: set[str] = set()
212
+ for each_argument in arguments.posonlyargs + arguments.args + arguments.kwonlyargs:
213
+ real_names.add(each_argument.arg)
214
+ if arguments.vararg is not None:
215
+ real_names.add(arguments.vararg.arg)
216
+ if arguments.kwarg is not None:
217
+ real_names.add(arguments.kwarg.arg)
218
+ return real_names - ALL_SELF_AND_CLS_PARAMETER_NAMES
219
+
220
+
221
+ def _is_docstring_terminating_section_header(stripped_line: str) -> bool:
222
+ return stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
223
+
224
+
225
+ def _documented_argument_names(docstring_text: str) -> list[str]:
226
+ docstring_lines = docstring_text.splitlines()
227
+ args_section_index = _find_args_section_index(docstring_lines)
228
+ if args_section_index is None:
229
+ return []
230
+ documented_names: list[str] = []
231
+ entry_indent: int | None = None
232
+ for each_line in docstring_lines[args_section_index + 1:]:
233
+ stripped_line = each_line.strip()
234
+ if not stripped_line:
235
+ continue
236
+ if _is_docstring_terminating_section_header(stripped_line):
237
+ break
238
+ current_indent = len(each_line) - len(each_line.lstrip())
239
+ if current_indent == 0:
240
+ break
241
+ if entry_indent is None:
242
+ entry_indent = current_indent
243
+ if current_indent > entry_indent:
244
+ continue
245
+ entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
246
+ if entry_match is not None:
247
+ documented_names.append(entry_match.group(1))
248
+ return documented_names
249
+
250
+
251
+ def _find_args_section_index(all_docstring_lines: list[str]) -> int | None:
252
+ for each_line_index, each_line in enumerate(all_docstring_lines):
253
+ if each_line.strip() in ALL_DOCSTRING_ARGS_SECTION_HEADERS:
254
+ return each_line_index
255
+ return None
256
+
257
+
258
+ def check_docstring_args_match_signature(content: str, file_path: str) -> list[str]:
259
+ """Flag docstring Args: entries naming a parameter the signature lacks.
260
+
261
+ A fix that renames a parameter often leaves the adjacent ``Args:`` line
262
+ stale. Each documented argument name is compared to the real signature;
263
+ a documented name with no matching parameter is reported. Only the
264
+ ``Args:`` section is validated — ``Raises:`` is left alone because
265
+ callee-propagated exceptions cause false positives. Functions that
266
+ accept ``**kwargs`` are skipped because their documented names may be
267
+ keyword keys the signature cannot enumerate.
268
+
269
+ Args:
270
+ content: The source text to inspect.
271
+ file_path: The path the source will be written to, used for exemptions.
272
+
273
+ Returns:
274
+ One issue per stale documented argument, capped at the module limit.
275
+ """
276
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
277
+ return []
278
+ try:
279
+ parsed_tree = ast.parse(content)
280
+ except SyntaxError:
281
+ return []
282
+ issues: list[str] = []
283
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
284
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
285
+ continue
286
+ if _function_is_private_or_dunder(each_node.name):
287
+ continue
288
+ if _function_has_exempt_decorator(each_node):
289
+ continue
290
+ if _function_body_line_count(each_node) <= DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT:
291
+ continue
292
+ if each_node.args.kwarg is not None:
293
+ continue
294
+ documented_names = _documented_argument_names(_function_docstring_text(each_node))
295
+ if not documented_names:
296
+ continue
297
+ real_names = _signature_parameter_names(each_node)
298
+ for each_documented_name in documented_names:
299
+ if each_documented_name in real_names:
300
+ continue
301
+ issues.append(
302
+ f"Line {each_node.lineno}: {each_node.name}() docstring Args: lists "
303
+ f"'{each_documented_name}' which is not a parameter - update the "
304
+ "docstring to match the signature"
305
+ )
306
+ if len(issues) >= MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES:
307
+ return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
308
+ return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]