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.
Files changed (34) hide show
  1. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +17 -3
  2. package/audit-rubrics/prompts/category-a-api-contracts.md +17 -2
  3. package/docs/CODE_RULES.md +6 -1
  4. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  5. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  6. package/hooks/blocking/code_rules_enforcer.py +386 -32
  7. package/hooks/blocking/conftest.py +30 -0
  8. package/hooks/blocking/md_to_html_blocker.py +2 -2
  9. package/hooks/blocking/pr_description_body_audit.py +148 -0
  10. package/hooks/blocking/pr_description_command_parser.py +233 -0
  11. package/hooks/blocking/pr_description_enforcer.py +36 -825
  12. package/hooks/blocking/pr_description_pr_number.py +153 -0
  13. package/hooks/blocking/pr_description_readability.py +366 -0
  14. package/hooks/blocking/test_code_rules_enforcer.py +65 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
  16. package/hooks/blocking/test_code_rules_enforcer_function_length.py +136 -5
  17. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
  18. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
  19. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  20. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  21. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  22. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  23. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  24. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  25. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  26. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  27. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  28. package/hooks/hooks_constants/blocking_check_limits.py +2 -0
  29. package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
  30. package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
  31. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  32. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
  33. package/package.json +1 -1
  34. 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 _statement_is_module_docstring(statement_node: ast.stmt) -> bool:
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 _statement_is_module_docstring(body_statements[0])
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 _statement_is_module_docstring(body_statements[0]):
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 check_boolean_naming(content: str, file_path: str) -> list[str]:
2444
- """Flag boolean assignments whose target name lacks a required prefix."""
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
- issues: list[str] = []
2463
- for name, line_number, is_in_upper_snake_scope in _collect_boolean_assignments(tree):
2464
- if len(name) == 1:
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 is_in_upper_snake_scope and UPPER_SNAKE_CONSTANT_PATTERN.match(name):
2653
+ if each_is_in_upper_snake_scope and UPPER_SNAKE_CONSTANT_PATTERN.match(each_name):
2467
2654
  continue
2468
- if name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
2655
+ if each_name.startswith(ALL_BOOLEAN_NAME_PREFIXES):
2469
2656
  continue
2470
- issues.append(
2471
- f"Line {line_number}: Boolean {name} - prefix with is_/has_/should_/can_"
2657
+ message = (
2658
+ f"Line {each_line_number}: Boolean {each_name} - prefix with "
2659
+ "is_/has_/should_/can_/was_/did_"
2472
2660
  )
2473
- return issues
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 definition span exceeds cognitive-load thresholds.
5424
-
5425
- Function definition spans (signature line through last body statement,
5426
- inclusive) at or above ``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` (60
5427
- lines) appear in the returned issues list and block the write at the
5428
- gate. The threshold rests on the small-function guidance in Robert C.
5429
- Martin, *Clean Code* Ch. 3 ("Functions") and the Google Python Style
5430
- Guide's ~40-line function review hint
5431
- (https://google.github.io/styleguide/pyguide.html); this gate blocks on
5432
- body growth that pushes a function past that span. It does not derive
5433
- from CODE_RULES §6.5, which governs advisory file-length signals and
5434
- argues against hard numeric blocks.
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 >= FUNCTION_LENGTH_BLOCKING_THRESHOLD:
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(check_boolean_naming(content, file_path))
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 CHANGELOG.md at any repo root\n"
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