claude-dev-env 1.49.1 → 1.50.1
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/audit-rubrics/category_rubrics/category-a-api-contracts.md +17 -3
- package/audit-rubrics/prompts/category-a-api-contracts.md +17 -2
- package/docs/CODE_RULES.md +6 -1
- package/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_enforcer.py +386 -32
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +2 -2
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/test_code_rules_enforcer.py +65 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +136 -5
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/hooks_constants/blocking_check_limits.py +2 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
- package/package.json +1 -1
- package/hooks/blocking/test_md_to_html_blocker.py +0 -772
|
@@ -91,7 +91,9 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
91
91
|
MAX_BANNED_PREFIX_ISSUES,
|
|
92
92
|
MAX_BARE_EXCEPT_ISSUES,
|
|
93
93
|
MAX_BOUNDARY_TYPE_ISSUES,
|
|
94
|
+
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
94
95
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
96
|
+
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
|
|
95
97
|
MAX_STUB_IMPLEMENTATION_ISSUES,
|
|
96
98
|
MAX_TEST_BRANCHING_ISSUES,
|
|
97
99
|
MAX_TYPED_DICT_PAIR_ISSUES,
|
|
@@ -132,6 +134,10 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
132
134
|
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE,
|
|
133
135
|
BARE_EACH_TOKEN,
|
|
134
136
|
ALL_BOOLEAN_NAME_PREFIXES,
|
|
137
|
+
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
138
|
+
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
139
|
+
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
140
|
+
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES,
|
|
135
141
|
ALL_BUILTIN_DICT_METHOD_NAMES,
|
|
136
142
|
ALL_CLI_FILE_PATH_MARKERS,
|
|
137
143
|
CHAINED_INLINE_COMMENT_PATTERN,
|
|
@@ -1717,7 +1723,7 @@ def _is_init_file(file_path: str) -> bool:
|
|
|
1717
1723
|
return file_path.replace("\\", "/").rsplit("/", 1)[-1] == "__init__.py"
|
|
1718
1724
|
|
|
1719
1725
|
|
|
1720
|
-
def
|
|
1726
|
+
def _statement_is_docstring(statement_node: ast.stmt) -> bool:
|
|
1721
1727
|
return (
|
|
1722
1728
|
isinstance(statement_node, ast.Expr)
|
|
1723
1729
|
and isinstance(statement_node.value, ast.Constant)
|
|
@@ -1772,7 +1778,7 @@ def check_thin_wrapper_files(content: str, file_path: str) -> list[str]:
|
|
|
1772
1778
|
|
|
1773
1779
|
statements_after_docstring = (
|
|
1774
1780
|
body_statements[1:]
|
|
1775
|
-
if
|
|
1781
|
+
if _statement_is_docstring(body_statements[0])
|
|
1776
1782
|
else body_statements
|
|
1777
1783
|
)
|
|
1778
1784
|
if not statements_after_docstring:
|
|
@@ -1931,11 +1937,7 @@ def _function_body_line_count(
|
|
|
1931
1937
|
if not function_node.body:
|
|
1932
1938
|
return 0
|
|
1933
1939
|
first_body_index = 0
|
|
1934
|
-
if (
|
|
1935
|
-
isinstance(function_node.body[0], ast.Expr)
|
|
1936
|
-
and isinstance(function_node.body[0].value, ast.Constant)
|
|
1937
|
-
and isinstance(function_node.body[0].value.value, str)
|
|
1938
|
-
):
|
|
1940
|
+
if _statement_is_docstring(function_node.body[0]):
|
|
1939
1941
|
if len(function_node.body) == 1:
|
|
1940
1942
|
return 0
|
|
1941
1943
|
first_body_index = 1
|
|
@@ -2092,6 +2094,110 @@ def check_docstring_format(content: str, file_path: str) -> list[str]:
|
|
|
2092
2094
|
return issues[:MAX_DOCSTRING_FORMAT_ISSUES]
|
|
2093
2095
|
|
|
2094
2096
|
|
|
2097
|
+
def _signature_parameter_names(
|
|
2098
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2099
|
+
) -> set[str]:
|
|
2100
|
+
arguments = function_node.args
|
|
2101
|
+
real_names: set[str] = set()
|
|
2102
|
+
for each_argument in arguments.posonlyargs + arguments.args + arguments.kwonlyargs:
|
|
2103
|
+
real_names.add(each_argument.arg)
|
|
2104
|
+
if arguments.vararg is not None:
|
|
2105
|
+
real_names.add(arguments.vararg.arg)
|
|
2106
|
+
if arguments.kwarg is not None:
|
|
2107
|
+
real_names.add(arguments.kwarg.arg)
|
|
2108
|
+
return real_names - ALL_SELF_AND_CLS_PARAMETER_NAMES
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
def _is_docstring_terminating_section_header(stripped_line: str) -> bool:
|
|
2112
|
+
return stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
def _documented_argument_names(docstring_text: str) -> list[str]:
|
|
2116
|
+
docstring_lines = docstring_text.splitlines()
|
|
2117
|
+
args_section_index = _find_args_section_index(docstring_lines)
|
|
2118
|
+
if args_section_index is None:
|
|
2119
|
+
return []
|
|
2120
|
+
documented_names: list[str] = []
|
|
2121
|
+
entry_indent: int | None = None
|
|
2122
|
+
for each_line in docstring_lines[args_section_index + 1:]:
|
|
2123
|
+
stripped_line = each_line.strip()
|
|
2124
|
+
if not stripped_line:
|
|
2125
|
+
continue
|
|
2126
|
+
if _is_docstring_terminating_section_header(stripped_line):
|
|
2127
|
+
break
|
|
2128
|
+
current_indent = len(each_line) - len(each_line.lstrip())
|
|
2129
|
+
if current_indent == 0:
|
|
2130
|
+
break
|
|
2131
|
+
if entry_indent is None:
|
|
2132
|
+
entry_indent = current_indent
|
|
2133
|
+
if current_indent > entry_indent:
|
|
2134
|
+
continue
|
|
2135
|
+
entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
|
|
2136
|
+
if entry_match is not None:
|
|
2137
|
+
documented_names.append(entry_match.group(1))
|
|
2138
|
+
return documented_names
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def _find_args_section_index(all_docstring_lines: list[str]) -> int | None:
|
|
2142
|
+
for each_line_index, each_line in enumerate(all_docstring_lines):
|
|
2143
|
+
if each_line.strip() in ALL_DOCSTRING_ARGS_SECTION_HEADERS:
|
|
2144
|
+
return each_line_index
|
|
2145
|
+
return None
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
def check_docstring_args_match_signature(content: str, file_path: str) -> list[str]:
|
|
2149
|
+
"""Flag docstring Args: entries naming a parameter the signature lacks.
|
|
2150
|
+
|
|
2151
|
+
A fix that renames a parameter often leaves the adjacent ``Args:`` line
|
|
2152
|
+
stale. Each documented argument name is compared to the real signature;
|
|
2153
|
+
a documented name with no matching parameter is reported. Only the
|
|
2154
|
+
``Args:`` section is validated — ``Raises:`` is left alone because
|
|
2155
|
+
callee-propagated exceptions cause false positives. Functions that
|
|
2156
|
+
accept ``**kwargs`` are skipped because their documented names may be
|
|
2157
|
+
keyword keys the signature cannot enumerate.
|
|
2158
|
+
|
|
2159
|
+
Args:
|
|
2160
|
+
content: The source text to inspect.
|
|
2161
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
2162
|
+
|
|
2163
|
+
Returns:
|
|
2164
|
+
One issue per stale documented argument, capped at the module limit.
|
|
2165
|
+
"""
|
|
2166
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
2167
|
+
return []
|
|
2168
|
+
try:
|
|
2169
|
+
parsed_tree = ast.parse(content)
|
|
2170
|
+
except SyntaxError:
|
|
2171
|
+
return []
|
|
2172
|
+
issues: list[str] = []
|
|
2173
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
2174
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2175
|
+
continue
|
|
2176
|
+
if _function_is_private_or_dunder(each_node.name):
|
|
2177
|
+
continue
|
|
2178
|
+
if _function_has_exempt_decorator(each_node):
|
|
2179
|
+
continue
|
|
2180
|
+
if _function_body_line_count(each_node) <= DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT:
|
|
2181
|
+
continue
|
|
2182
|
+
if each_node.args.kwarg is not None:
|
|
2183
|
+
continue
|
|
2184
|
+
documented_names = _documented_argument_names(_function_docstring_text(each_node))
|
|
2185
|
+
if not documented_names:
|
|
2186
|
+
continue
|
|
2187
|
+
real_names = _signature_parameter_names(each_node)
|
|
2188
|
+
for each_documented_name in documented_names:
|
|
2189
|
+
if each_documented_name in real_names:
|
|
2190
|
+
continue
|
|
2191
|
+
issues.append(
|
|
2192
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring Args: lists "
|
|
2193
|
+
f"'{each_documented_name}' which is not a parameter - update the "
|
|
2194
|
+
"docstring to match the signature"
|
|
2195
|
+
)
|
|
2196
|
+
if len(issues) >= MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES:
|
|
2197
|
+
return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
|
|
2198
|
+
return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
|
|
2199
|
+
|
|
2200
|
+
|
|
2095
2201
|
_PASCAL_TO_SNAKE_WORD_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
2096
2202
|
|
|
2097
2203
|
|
|
@@ -2245,7 +2351,7 @@ def _statement_is_raise_not_implemented(statement_node: ast.stmt) -> bool:
|
|
|
2245
2351
|
|
|
2246
2352
|
def _function_body_is_stub(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
2247
2353
|
body_statements = list(function_node.body)
|
|
2248
|
-
if body_statements and
|
|
2354
|
+
if body_statements and _statement_is_docstring(body_statements[0]):
|
|
2249
2355
|
body_statements = body_statements[1:]
|
|
2250
2356
|
if len(body_statements) != 1:
|
|
2251
2357
|
return False
|
|
@@ -2440,8 +2546,89 @@ def _collect_boolean_assignments(tree: ast.Module) -> list[tuple[str, int, bool]
|
|
|
2440
2546
|
return collected
|
|
2441
2547
|
|
|
2442
2548
|
|
|
2443
|
-
def
|
|
2444
|
-
|
|
2549
|
+
def _argument_is_boolean(argument_node: ast.arg, default_node: ast.expr | None) -> bool:
|
|
2550
|
+
annotation_is_bool = (
|
|
2551
|
+
isinstance(argument_node.annotation, ast.Name)
|
|
2552
|
+
and argument_node.annotation.id == "bool"
|
|
2553
|
+
)
|
|
2554
|
+
default_is_bool = default_node is not None and _is_bool_constant(default_node)
|
|
2555
|
+
return annotation_is_bool or default_is_bool
|
|
2556
|
+
|
|
2557
|
+
|
|
2558
|
+
def _bool_parameters_for_function(
|
|
2559
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2560
|
+
) -> list[tuple[str, int]]:
|
|
2561
|
+
arguments = function_node.args
|
|
2562
|
+
positional_arguments = arguments.posonlyargs + arguments.args
|
|
2563
|
+
positional_defaults = arguments.defaults
|
|
2564
|
+
leading_without_default = len(positional_arguments) - len(positional_defaults)
|
|
2565
|
+
bool_parameters: list[tuple[str, int]] = []
|
|
2566
|
+
for each_position, each_argument in enumerate(positional_arguments):
|
|
2567
|
+
default_index = each_position - leading_without_default
|
|
2568
|
+
default_node = (
|
|
2569
|
+
positional_defaults[default_index] if default_index >= 0 else None
|
|
2570
|
+
)
|
|
2571
|
+
if each_argument.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
2572
|
+
continue
|
|
2573
|
+
if _argument_is_boolean(each_argument, default_node):
|
|
2574
|
+
bool_parameters.append((each_argument.arg, each_argument.lineno))
|
|
2575
|
+
for each_argument, each_default in zip(arguments.kwonlyargs, arguments.kw_defaults):
|
|
2576
|
+
if each_argument.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
2577
|
+
continue
|
|
2578
|
+
if _argument_is_boolean(each_argument, each_default):
|
|
2579
|
+
bool_parameters.append((each_argument.arg, each_argument.lineno))
|
|
2580
|
+
return bool_parameters
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
def _collect_bool_parameter_names(tree: ast.Module) -> list[tuple[str, int]]:
|
|
2584
|
+
"""Collect (name, line_number) for boolean-typed function parameters.
|
|
2585
|
+
|
|
2586
|
+
A parameter counts as boolean when its annotation is the ``bool`` name or
|
|
2587
|
+
its default is a boolean literal. ``self`` and ``cls`` are skipped.
|
|
2588
|
+
|
|
2589
|
+
Args:
|
|
2590
|
+
tree: The parsed module to inspect.
|
|
2591
|
+
|
|
2592
|
+
Returns:
|
|
2593
|
+
Each boolean parameter as a (name, line_number) pair.
|
|
2594
|
+
"""
|
|
2595
|
+
bool_parameters: list[tuple[str, int]] = []
|
|
2596
|
+
for each_node in ast.walk(tree):
|
|
2597
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2598
|
+
bool_parameters.extend(_bool_parameters_for_function(each_node))
|
|
2599
|
+
return bool_parameters
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
def check_boolean_naming(
|
|
2603
|
+
content: str,
|
|
2604
|
+
file_path: str,
|
|
2605
|
+
all_changed_lines: set[int] | None = None,
|
|
2606
|
+
defer_scope_to_caller: bool = False,
|
|
2607
|
+
) -> list[str]:
|
|
2608
|
+
"""Flag boolean assignments and parameters whose name lacks a required prefix.
|
|
2609
|
+
|
|
2610
|
+
The caller passes the reconstructed full file as *content* so ``ast.parse``
|
|
2611
|
+
sees a complete module rather than an Edit's ``new_string`` fragment, which is
|
|
2612
|
+
rarely valid standalone Python. Findings are then scoped to *all_changed_lines*
|
|
2613
|
+
so an Edit blocks on the unprefixed boolean it just introduced while a
|
|
2614
|
+
pre-existing violation on an untouched line does not block the edit.
|
|
2615
|
+
|
|
2616
|
+
Args:
|
|
2617
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
2618
|
+
Edit so the parse succeeds.
|
|
2619
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
2620
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
2621
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
2622
|
+
blocks only when its source line intersects the changed lines.
|
|
2623
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
2624
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
2625
|
+
line.
|
|
2626
|
+
|
|
2627
|
+
Returns:
|
|
2628
|
+
One issue per unprefixed boolean assignment and parameter, scoped to the
|
|
2629
|
+
changed lines unless *defer_scope_to_caller* is True or *all_changed_lines*
|
|
2630
|
+
is None. This check has no module cap.
|
|
2631
|
+
"""
|
|
2445
2632
|
if is_test_file(file_path):
|
|
2446
2633
|
return []
|
|
2447
2634
|
if is_hook_infrastructure(file_path):
|
|
@@ -2459,20 +2646,125 @@ def check_boolean_naming(content: str, file_path: str) -> list[str]:
|
|
|
2459
2646
|
file=sys.stderr,
|
|
2460
2647
|
)
|
|
2461
2648
|
return []
|
|
2462
|
-
|
|
2463
|
-
for
|
|
2464
|
-
if len(
|
|
2649
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
2650
|
+
for each_name, each_line_number, each_is_in_upper_snake_scope in _collect_boolean_assignments(tree):
|
|
2651
|
+
if len(each_name) == 1:
|
|
2465
2652
|
continue
|
|
2466
|
-
if
|
|
2653
|
+
if each_is_in_upper_snake_scope and UPPER_SNAKE_CONSTANT_PATTERN.match(each_name):
|
|
2467
2654
|
continue
|
|
2468
|
-
if
|
|
2655
|
+
if each_name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
|
|
2469
2656
|
continue
|
|
2470
|
-
|
|
2471
|
-
f"Line {
|
|
2657
|
+
message = (
|
|
2658
|
+
f"Line {each_line_number}: Boolean {each_name} - prefix with "
|
|
2659
|
+
"is_/has_/should_/can_/was_/did_"
|
|
2472
2660
|
)
|
|
2473
|
-
|
|
2661
|
+
all_violations_in_walk_order.append(
|
|
2662
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
2663
|
+
)
|
|
2664
|
+
for each_name, each_line_number in _collect_bool_parameter_names(tree):
|
|
2665
|
+
if len(each_name) == 1:
|
|
2666
|
+
continue
|
|
2667
|
+
if each_name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
|
|
2668
|
+
continue
|
|
2669
|
+
message = (
|
|
2670
|
+
f"Line {each_line_number}: Boolean parameter {each_name} - prefix with "
|
|
2671
|
+
"is_/has_/should_/can_/was_/did_"
|
|
2672
|
+
)
|
|
2673
|
+
all_violations_in_walk_order.append(
|
|
2674
|
+
(range(each_line_number, each_line_number + 1), message)
|
|
2675
|
+
)
|
|
2676
|
+
return _scope_violations_to_changed_lines(
|
|
2677
|
+
all_violations_in_walk_order,
|
|
2678
|
+
all_changed_lines,
|
|
2679
|
+
defer_scope_to_caller,
|
|
2680
|
+
)
|
|
2474
2681
|
|
|
2475
2682
|
|
|
2683
|
+
def _called_terminal_name(call_node: ast.Call) -> str | None:
|
|
2684
|
+
callee = call_node.func
|
|
2685
|
+
if isinstance(callee, ast.Name):
|
|
2686
|
+
return callee.id
|
|
2687
|
+
if isinstance(callee, ast.Attribute):
|
|
2688
|
+
return callee.attr
|
|
2689
|
+
return None
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def check_ignored_must_check_return(
|
|
2693
|
+
content: str,
|
|
2694
|
+
file_path: str,
|
|
2695
|
+
all_changed_lines: set[int] | None = None,
|
|
2696
|
+
defer_scope_to_caller: bool = False,
|
|
2697
|
+
) -> list[str]:
|
|
2698
|
+
"""Flag bare-expression calls whose discarded return is the only failure signal.
|
|
2699
|
+
|
|
2700
|
+
Functions in ``ALL_MUST_CHECK_RETURN_FUNCTION_NAMES`` report success or failure
|
|
2701
|
+
solely through their return value. A bare-statement call discards that value,
|
|
2702
|
+
so the caller silently proceeds on failure. Bare ``ast.Expr`` calls are flagged,
|
|
2703
|
+
including a bare ``await``-wrapped call (``await find_and_click(...)`` as a
|
|
2704
|
+
statement); an assigned or branched-on call is exempt.
|
|
2705
|
+
|
|
2706
|
+
The caller passes the reconstructed full file as *content* so ``ast.parse``
|
|
2707
|
+
sees a complete module rather than an Edit's ``new_string`` fragment, which is
|
|
2708
|
+
rarely valid standalone Python (a bare ``await find_and_click(...)`` line is a
|
|
2709
|
+
SyntaxError on its own). Findings are then scoped to *all_changed_lines* so an
|
|
2710
|
+
Edit blocks on the discarded return it just introduced while a pre-existing
|
|
2711
|
+
violation on an untouched line does not block the edit.
|
|
2712
|
+
|
|
2713
|
+
Args:
|
|
2714
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
2715
|
+
Edit so the parse succeeds.
|
|
2716
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
2717
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
2718
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
2719
|
+
blocks only when the bare call's line intersects the changed lines.
|
|
2720
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
2721
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
2722
|
+
line.
|
|
2723
|
+
|
|
2724
|
+
Returns:
|
|
2725
|
+
One issue per discarded must-check return, scoped to the changed lines
|
|
2726
|
+
unless *defer_scope_to_caller* is True or *all_changed_lines* is None. When
|
|
2727
|
+
*defer_scope_to_caller* is True every violation is returned uncapped so the
|
|
2728
|
+
gate can scope by added line and apply its own ceiling; otherwise the
|
|
2729
|
+
terminal result is capped at the module limit.
|
|
2730
|
+
"""
|
|
2731
|
+
if is_test_file(file_path):
|
|
2732
|
+
return []
|
|
2733
|
+
try:
|
|
2734
|
+
tree = ast.parse(content)
|
|
2735
|
+
except SyntaxError:
|
|
2736
|
+
return []
|
|
2737
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
2738
|
+
for each_node in ast.walk(tree):
|
|
2739
|
+
if not isinstance(each_node, ast.Expr):
|
|
2740
|
+
continue
|
|
2741
|
+
expression_value = each_node.value
|
|
2742
|
+
call_node = (
|
|
2743
|
+
expression_value.value
|
|
2744
|
+
if isinstance(expression_value, ast.Await)
|
|
2745
|
+
else expression_value
|
|
2746
|
+
)
|
|
2747
|
+
if not isinstance(call_node, ast.Call):
|
|
2748
|
+
continue
|
|
2749
|
+
called_name = _called_terminal_name(call_node)
|
|
2750
|
+
if called_name is None or called_name not in ALL_MUST_CHECK_RETURN_FUNCTION_NAMES:
|
|
2751
|
+
continue
|
|
2752
|
+
end_line_number = each_node.end_lineno or each_node.lineno
|
|
2753
|
+
line_span = range(each_node.lineno, end_line_number + 1)
|
|
2754
|
+
message = (
|
|
2755
|
+
f"Line {each_node.lineno}: return value of {called_name}() is discarded - "
|
|
2756
|
+
"assign and check it (the boolean/outcome is the only failure signal)"
|
|
2757
|
+
)
|
|
2758
|
+
all_violations_in_walk_order.append((line_span, message))
|
|
2759
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
2760
|
+
all_violations_in_walk_order,
|
|
2761
|
+
all_changed_lines,
|
|
2762
|
+
defer_scope_to_caller,
|
|
2763
|
+
)
|
|
2764
|
+
if defer_scope_to_caller:
|
|
2765
|
+
return scoped_issues
|
|
2766
|
+
return scoped_issues[:MAX_IGNORED_MUST_CHECK_RETURN_ISSUES]
|
|
2767
|
+
|
|
2476
2768
|
|
|
2477
2769
|
def _decorator_name_contains_skip(decorator_node: ast.expr) -> bool:
|
|
2478
2770
|
"""Return True when a decorator AST node references an identifier containing 'skip'."""
|
|
@@ -5334,6 +5626,37 @@ def _function_definition_line_span(
|
|
|
5334
5626
|
return end_lineno - function_node.lineno + 1
|
|
5335
5627
|
|
|
5336
5628
|
|
|
5629
|
+
def _definition_docstring_line_span(
|
|
5630
|
+
definition_node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
5631
|
+
) -> int:
|
|
5632
|
+
"""Return the source-line count of the definition's leading docstring.
|
|
5633
|
+
|
|
5634
|
+
The Google Python Style Guide pairs a small-function preference that
|
|
5635
|
+
targets executable complexity (§3.18) with a requirement for complete
|
|
5636
|
+
docstrings on public functions and classes (§3.8). Counting those
|
|
5637
|
+
docstring lines toward the function-length gate would penalize the very
|
|
5638
|
+
documentation §3.8 mandates, so the gate measures executable span and
|
|
5639
|
+
excludes leading docstring statements.
|
|
5640
|
+
|
|
5641
|
+
Args:
|
|
5642
|
+
definition_node: The function, method, or class definition node to
|
|
5643
|
+
inspect.
|
|
5644
|
+
|
|
5645
|
+
Returns:
|
|
5646
|
+
The number of source lines the leading docstring statement occupies,
|
|
5647
|
+
or zero when the definition body is empty or does not open with a
|
|
5648
|
+
string literal.
|
|
5649
|
+
"""
|
|
5650
|
+
definition_body = definition_node.body
|
|
5651
|
+
if not definition_body:
|
|
5652
|
+
return 0
|
|
5653
|
+
first_statement = definition_body[0]
|
|
5654
|
+
if _statement_is_docstring(first_statement):
|
|
5655
|
+
docstring_end = getattr(first_statement, "end_lineno", None) or first_statement.lineno
|
|
5656
|
+
return docstring_end - first_statement.lineno + 1
|
|
5657
|
+
return 0
|
|
5658
|
+
|
|
5659
|
+
|
|
5337
5660
|
def changed_line_numbers(prior_content: str, post_edit_content: str) -> set[int]:
|
|
5338
5661
|
"""Return the post-edit line numbers an edit added or replaced.
|
|
5339
5662
|
|
|
@@ -5420,18 +5743,23 @@ def check_function_length(
|
|
|
5420
5743
|
all_changed_lines: set[int] | None = None,
|
|
5421
5744
|
defer_scope_to_caller: bool = False,
|
|
5422
5745
|
) -> list[str]:
|
|
5423
|
-
"""Flag functions whose
|
|
5424
|
-
|
|
5425
|
-
Function
|
|
5426
|
-
inclusive)
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5746
|
+
"""Flag functions whose executable span exceeds cognitive-load thresholds.
|
|
5747
|
+
|
|
5748
|
+
Function executable spans — the definition span (signature line through
|
|
5749
|
+
last body statement, inclusive) minus the leading docstring lines of the
|
|
5750
|
+
function and of every function or class nested within it, per
|
|
5751
|
+
``_definition_docstring_line_span`` summed over the nested definitions —
|
|
5752
|
+
at or above
|
|
5753
|
+
``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` (60 lines) appear in the returned
|
|
5754
|
+
issues list and block the write at the gate. The threshold rests on the
|
|
5755
|
+
small-function guidance in Robert C. Martin, *Clean Code* Ch. 3
|
|
5756
|
+
("Functions") and the Google Python Style Guide's ~40-line function review
|
|
5757
|
+
hint (https://google.github.io/styleguide/pyguide.html) — a measure of
|
|
5758
|
+
executable complexity, paired with the Guide's complete-docstring mandate
|
|
5759
|
+
for public APIs, so documentation lines never count against the gate; this
|
|
5760
|
+
gate blocks on body growth that pushes a function past that span. It does
|
|
5761
|
+
not derive from CODE_RULES §6.5, which governs advisory file-length
|
|
5762
|
+
signals and argues against hard numeric blocks.
|
|
5435
5763
|
|
|
5436
5764
|
The issue message carries ``Function NAME (defined at line X) is Y lines``
|
|
5437
5765
|
precisely so the gate's ``function_length_span_range`` can recover the
|
|
@@ -5480,7 +5808,17 @@ def check_function_length(
|
|
|
5480
5808
|
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
5481
5809
|
continue
|
|
5482
5810
|
line_span = _function_definition_line_span(each_node)
|
|
5483
|
-
if line_span
|
|
5811
|
+
if line_span < FUNCTION_LENGTH_BLOCKING_THRESHOLD:
|
|
5812
|
+
continue
|
|
5813
|
+
docstring_line_total = sum(
|
|
5814
|
+
_definition_docstring_line_span(each_definition)
|
|
5815
|
+
for each_definition in ast.walk(each_node)
|
|
5816
|
+
if isinstance(
|
|
5817
|
+
each_definition, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
5818
|
+
)
|
|
5819
|
+
)
|
|
5820
|
+
executable_line_span = line_span - docstring_line_total
|
|
5821
|
+
if executable_line_span >= FUNCTION_LENGTH_BLOCKING_THRESHOLD:
|
|
5484
5822
|
span_range = range(each_node.lineno, each_node.lineno + line_span)
|
|
5485
5823
|
message = (
|
|
5486
5824
|
f"Function {each_node.name!r} (defined at line {each_node.lineno}) "
|
|
@@ -5570,7 +5908,23 @@ def validate_content(
|
|
|
5570
5908
|
all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
|
|
5571
5909
|
all_issues.extend(check_boundary_types(effective_content, file_path))
|
|
5572
5910
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
5573
|
-
all_issues.extend(
|
|
5911
|
+
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
5912
|
+
all_issues.extend(
|
|
5913
|
+
check_boolean_naming(
|
|
5914
|
+
effective_content,
|
|
5915
|
+
file_path,
|
|
5916
|
+
all_changed_lines,
|
|
5917
|
+
defer_scope_to_caller,
|
|
5918
|
+
)
|
|
5919
|
+
)
|
|
5920
|
+
all_issues.extend(
|
|
5921
|
+
check_ignored_must_check_return(
|
|
5922
|
+
effective_content,
|
|
5923
|
+
file_path,
|
|
5924
|
+
all_changed_lines,
|
|
5925
|
+
defer_scope_to_caller,
|
|
5926
|
+
)
|
|
5927
|
+
)
|
|
5574
5928
|
all_issues.extend(check_skip_decorators_in_tests(content, file_path))
|
|
5575
5929
|
all_issues.extend(
|
|
5576
5930
|
check_tests_use_isolated_filesystem_paths(
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Session-scoped cleanup fixture for the md_to_html_blocker test suites.
|
|
2
|
+
|
|
3
|
+
The md_to_html_blocker suites share one lazily-created sandbox parent
|
|
4
|
+
directory under the home directory. This fixture tears that sandbox down once
|
|
5
|
+
the session ends so the suites leave no residue regardless of which split file
|
|
6
|
+
pytest collects first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
|
|
16
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
18
|
+
|
|
19
|
+
from _md_to_html_blocker_test_support import ( # noqa: E402
|
|
20
|
+
_force_rmtree,
|
|
21
|
+
_get_sandbox_parent_directory,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
26
|
+
def _cleanup_sandbox_parent_directory():
|
|
27
|
+
yield
|
|
28
|
+
if _get_sandbox_parent_directory.cache_info().currsize:
|
|
29
|
+
_force_rmtree(_get_sandbox_parent_directory())
|
|
30
|
+
_get_sandbox_parent_directory.cache_clear()
|
|
@@ -68,7 +68,7 @@ def _block_context() -> str:
|
|
|
68
68
|
f"- Files under {_exempt_plugin_segments_summary} directories\n"
|
|
69
69
|
f"- Files under {_claude_dev_env_source_directories_summary} source directories\n"
|
|
70
70
|
f"- Files under any directory whose ancestor contains {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/\n"
|
|
71
|
-
"- README.md and
|
|
71
|
+
"- README.md, CHANGELOG.md, CLAUDE.md, and AGENTS.md at any repo root\n"
|
|
72
72
|
f"- Files under {_exempt_home_directories_summary}\n"
|
|
73
73
|
"- Files under the OS temp directory"
|
|
74
74
|
)
|
|
@@ -83,7 +83,7 @@ def _block_system_message() -> str:
|
|
|
83
83
|
f"{_exempt_anywhere_filenames_summary} anywhere, {_exempt_plugin_segments_summary} trees, "
|
|
84
84
|
f"{_claude_dev_env_source_directories_summary} source trees, "
|
|
85
85
|
f"files under a {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/ root, "
|
|
86
|
-
f"README.md/CHANGELOG.md at any repo root, {_exempt_home_directories_summary}, "
|
|
86
|
+
f"README.md/CHANGELOG.md/CLAUDE.md/AGENTS.md at any repo root, {_exempt_home_directories_summary}, "
|
|
87
87
|
"and the OS temp directory."
|
|
88
88
|
)
|
|
89
89
|
|