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.
- package/_shared/pr-loop/audit-contract.md +3 -3
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +3 -2
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +5 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/docs/CODE_RULES.md +1 -1
- 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 -5807
- 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/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 +18 -13
- 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_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/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +13 -7
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +21 -11
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +92 -0
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +20 -48
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/audit-contract.md +4 -4
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/findbugs/SKILL.md +21 -12
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/qbug/SKILL.md +5 -5
- package/skills/qbug/test_qbug_skill_audit_schema.py +13 -23
- package/skills/refine/SKILL.md +1 -1
- 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]
|