claude-dev-env 1.76.0 → 1.78.0

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 (31) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  2. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  3. package/hooks/blocking/CLAUDE.md +3 -1
  4. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  5. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  6. package/hooks/blocking/code_rules_docstrings.py +730 -0
  7. package/hooks/blocking/code_rules_enforcer.py +39 -0
  8. package/hooks/blocking/code_rules_paired_test.py +299 -0
  9. package/hooks/blocking/code_rules_string_magic.py +71 -1
  10. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  11. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  12. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  16. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  17. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  18. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
  19. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  20. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  21. package/hooks/hooks_constants/CLAUDE.md +1 -0
  22. package/hooks/hooks_constants/blocking_check_limits.py +51 -0
  23. package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
  24. package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
  25. package/package.json +1 -1
  26. package/rules/CLAUDE.md +2 -0
  27. package/rules/docstring-prose-matches-implementation.md +5 -1
  28. package/rules/file-global-constants.md +2 -2
  29. package/rules/package-inventory-stale-entry.md +8 -0
  30. package/rules/paired-test-coverage.md +28 -0
  31. package/rules/plain-illustrative-docstrings.md +56 -0
@@ -31,24 +31,49 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
31
31
  ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
32
32
  ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
33
33
  ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
34
+ ALL_DOCSTRING_RUNON_JOINER_MARKERS,
34
35
  ALL_GENERIC_CHECK_NAME_TOKENS,
35
36
  ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
37
+ ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES,
36
38
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
37
39
  DOCSTRING_REFERENCE_MARKER_WINDOW,
40
+ DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
41
+ DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
38
42
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
39
43
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
44
+ MAX_COMPANION_MODULE_RESOLUTION_DEPTH,
45
+ PYTHON_MODULE_FILE_SUFFIX,
46
+ WORD_BOUNDARY_REGEX,
40
47
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
48
+ MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
41
49
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
42
50
  MAX_DOCSTRING_FORMAT_ISSUES,
43
51
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
52
+ MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES,
44
53
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
54
+ MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
55
+ ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
56
+ ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
57
+ ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
58
+ MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
59
+ ALL_ZIPFILE_WRITE_MODE_VALUES,
60
+ DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME,
61
+ MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES,
45
62
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
46
63
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
47
64
  MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
65
+ ZIPFILE_ALLOW_ZIP64_KEYWORD,
66
+ ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX,
67
+ ZIPFILE_MODE_KEYWORD,
68
+ ZIPFILE_MODE_POSITIONAL_INDEX,
69
+ ZIPFILE_WRITER_CLASS_NAME,
48
70
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
49
71
  MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
50
72
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
73
+ MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
74
+ MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
51
75
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
76
+ MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
52
77
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
53
78
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
54
79
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
@@ -57,10 +82,13 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
57
82
  )
58
83
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
59
84
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
85
+ ALL_CARDINAL_NUMBER_WORD_VALUES,
60
86
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
61
87
  ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
62
88
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
63
89
  DOCSTRING_ARG_ENTRY_PATTERN,
90
+ DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN,
91
+ DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN,
64
92
  DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
65
93
  IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
66
94
  INLINE_CODE_TOKEN_PATTERN,
@@ -1010,6 +1038,203 @@ def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> lis
1010
1038
  return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
1011
1039
 
1012
1040
 
1041
+ def _known_glyph_marker_members(
1042
+ sequence_node: ast.Tuple | ast.List,
1043
+ ) -> frozenset[str] | None:
1044
+ normalized_glyphs: set[str] = set()
1045
+ for each_element in sequence_node.elts:
1046
+ if not (
1047
+ isinstance(each_element, ast.Constant)
1048
+ and isinstance(each_element.value, str)
1049
+ ):
1050
+ return None
1051
+ normalized_glyph = each_element.value.strip()
1052
+ if normalized_glyph not in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES:
1053
+ return None
1054
+ normalized_glyphs.add(normalized_glyph)
1055
+ return frozenset(normalized_glyphs)
1056
+
1057
+
1058
+ def _assignment_targets_and_sequence(
1059
+ statement: ast.stmt,
1060
+ ) -> tuple[list[str], ast.Tuple | ast.List | None]:
1061
+ if isinstance(statement, ast.Assign) and isinstance(
1062
+ statement.value, (ast.Tuple, ast.List)
1063
+ ):
1064
+ plain_target_names = [
1065
+ each_target.id
1066
+ for each_target in statement.targets
1067
+ if isinstance(each_target, ast.Name)
1068
+ ]
1069
+ return plain_target_names, statement.value
1070
+ if (
1071
+ isinstance(statement, ast.AnnAssign)
1072
+ and isinstance(statement.target, ast.Name)
1073
+ and isinstance(statement.value, (ast.Tuple, ast.List))
1074
+ ):
1075
+ return [statement.target.id], statement.value
1076
+ return [], None
1077
+
1078
+
1079
+ def _module_glyph_marker_tuples(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
1080
+ glyphs_by_constant: dict[str, frozenset[str]] = {}
1081
+ for each_statement in parsed_tree.body:
1082
+ target_names, sequence_node = _assignment_targets_and_sequence(each_statement)
1083
+ if sequence_node is None:
1084
+ continue
1085
+ marker_glyphs = _known_glyph_marker_members(sequence_node)
1086
+ if marker_glyphs is None:
1087
+ continue
1088
+ if len(marker_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
1089
+ continue
1090
+ for each_target_name in target_names:
1091
+ glyphs_by_constant[each_target_name] = marker_glyphs
1092
+ return glyphs_by_constant
1093
+
1094
+
1095
+ def _companion_module_file(dotted_module: str, file_path: str) -> Path | None:
1096
+ relative_module_path = Path(*dotted_module.split(".")).with_suffix(
1097
+ PYTHON_MODULE_FILE_SUFFIX
1098
+ )
1099
+ file_directory = Path(file_path).parent
1100
+ candidate_roots = [file_directory, *file_directory.parents]
1101
+ for each_root in candidate_roots[:MAX_COMPANION_MODULE_RESOLUTION_DEPTH]:
1102
+ candidate_path = each_root / relative_module_path
1103
+ if candidate_path.is_file():
1104
+ return candidate_path
1105
+ return None
1106
+
1107
+
1108
+ def _companion_glyph_marker_tuples(companion_path: Path) -> dict[str, frozenset[str]]:
1109
+ try:
1110
+ companion_source = companion_path.read_text(encoding="utf-8")
1111
+ companion_tree = ast.parse(companion_source)
1112
+ except (OSError, ValueError, SyntaxError):
1113
+ return {}
1114
+ return _module_glyph_marker_tuples(companion_tree)
1115
+
1116
+
1117
+ def _import_brings_upper_snake_name(import_node: ast.ImportFrom) -> bool:
1118
+ return any(
1119
+ ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_alias.name)
1120
+ for each_alias in import_node.names
1121
+ )
1122
+
1123
+
1124
+ def _imported_glyph_marker_tuples(
1125
+ parsed_tree: ast.Module, file_path: str
1126
+ ) -> dict[str, frozenset[str]]:
1127
+ glyphs_by_imported_name: dict[str, frozenset[str]] = {}
1128
+ for each_statement in parsed_tree.body:
1129
+ if not isinstance(each_statement, ast.ImportFrom):
1130
+ continue
1131
+ if not each_statement.module:
1132
+ continue
1133
+ if not _import_brings_upper_snake_name(each_statement):
1134
+ continue
1135
+ companion_path = _companion_module_file(each_statement.module, file_path)
1136
+ if companion_path is None:
1137
+ continue
1138
+ companion_tuples = _companion_glyph_marker_tuples(companion_path)
1139
+ for each_alias in each_statement.names:
1140
+ if each_alias.name not in companion_tuples:
1141
+ continue
1142
+ imported_name = each_alias.asname or each_alias.name
1143
+ glyphs_by_imported_name[imported_name] = companion_tuples[each_alias.name]
1144
+ return glyphs_by_imported_name
1145
+
1146
+
1147
+ def _docstring_names_mark_glyph(docstring_text: str, normalized_glyph: str) -> bool:
1148
+ lowercased_docstring = docstring_text.lower()
1149
+ for each_name in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES[normalized_glyph]:
1150
+ boundary_wrapped_name = (
1151
+ WORD_BOUNDARY_REGEX + re.escape(each_name) + WORD_BOUNDARY_REGEX
1152
+ )
1153
+ if re.search(boundary_wrapped_name, lowercased_docstring):
1154
+ return True
1155
+ return False
1156
+
1157
+
1158
+ def _marks_named_in_docstring(
1159
+ docstring_text: str, all_marker_glyphs: frozenset[str]
1160
+ ) -> set[str]:
1161
+ return {
1162
+ each_glyph
1163
+ for each_glyph in all_marker_glyphs
1164
+ if _docstring_names_mark_glyph(docstring_text, each_glyph)
1165
+ }
1166
+
1167
+
1168
+ def _text_names_multiple_marks(content_text: str) -> bool:
1169
+ all_known_glyphs = frozenset(ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES)
1170
+ named_glyphs = _marks_named_in_docstring(content_text, all_known_glyphs)
1171
+ return len(named_glyphs) >= MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION
1172
+
1173
+
1174
+ def check_docstring_punctuation_mark_enumeration_coverage(
1175
+ content: str, file_path: str
1176
+ ) -> list[str]:
1177
+ """Flag a docstring that names some marks of a glyph tuple but omits one.
1178
+
1179
+ A module reads a tuple of punctuation-mark glyphs as its detection set. The
1180
+ tuple is defined in the module or imported from a companion module beside it.
1181
+ A docstring then enumerates those marks by their English names. When the
1182
+ prose names a closed set of marks but leaves one the tuple holds unnamed, a
1183
+ reader trusts the enumeration and believes an active mark never triggers the
1184
+ check. This is the shape that appears when a glyph joins the tuple while the
1185
+ prose enumeration stays as it was.
1186
+
1187
+ The check binds only when a docstring names two or more marks of one tuple. A
1188
+ docstring that mentions a single mark, names every mark, or describes
1189
+ unrelated punctuation is left alone. This is the deterministic glyph-prose
1190
+ slice of Category O6 docstring-prose-vs-implementation drift, the companion
1191
+ to check_docstring_tuple_enumeration_match for glyph members named in prose
1192
+ rather than identifier members named in inline code. It covers hook
1193
+ infrastructure, where the affected detection tuples live.
1194
+
1195
+ Args:
1196
+ content: The source text to inspect.
1197
+ file_path: The path the source will be written to, used for exemptions.
1198
+
1199
+ Returns:
1200
+ One issue per docstring whose mark enumeration omits a glyph the tuple it
1201
+ describes holds, capped at the module limit.
1202
+ """
1203
+ if is_strict_test_file(file_path):
1204
+ return []
1205
+ if not _text_names_multiple_marks(content):
1206
+ return []
1207
+ try:
1208
+ parsed_tree = ast.parse(content)
1209
+ except SyntaxError:
1210
+ return []
1211
+ glyphs_by_constant = {
1212
+ **_module_glyph_marker_tuples(parsed_tree),
1213
+ **_imported_glyph_marker_tuples(parsed_tree, file_path),
1214
+ }
1215
+ if not glyphs_by_constant:
1216
+ return []
1217
+ issues: list[str] = []
1218
+ for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
1219
+ for each_constant_name in sorted(glyphs_by_constant):
1220
+ marker_glyphs = glyphs_by_constant[each_constant_name]
1221
+ named_glyphs = _marks_named_in_docstring(each_docstring, marker_glyphs)
1222
+ if len(named_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
1223
+ continue
1224
+ omitted_glyphs = marker_glyphs - named_glyphs
1225
+ if not omitted_glyphs:
1226
+ continue
1227
+ issues.append(
1228
+ f"Line {each_line}: docstring names {sorted(named_glyphs)} from "
1229
+ f"{each_constant_name} but omits {sorted(omitted_glyphs)} — name every "
1230
+ "mark the tuple holds so the enumeration matches the detection set "
1231
+ "(Category O6 docstring-vs-implementation drift)"
1232
+ )
1233
+ if len(issues) >= MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES:
1234
+ return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
1235
+ return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
1236
+
1237
+
1013
1238
  def _returns_section_text(docstring_text: str) -> str:
1014
1239
  docstring_lines = docstring_text.splitlines()
1015
1240
  returns_section_lines: list[str] = []
@@ -1111,6 +1336,122 @@ def check_docstring_returns_plural_cardinality(content: str, file_path: str) ->
1111
1336
  return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1112
1337
 
1113
1338
 
1339
+ def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1340
+ members_by_family: dict[str, set[str]] = {}
1341
+ for each_node in ast.walk(parsed_tree):
1342
+ if not isinstance(each_node, ast.Name):
1343
+ continue
1344
+ if not isinstance(each_node.ctx, ast.Load):
1345
+ continue
1346
+ if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_node.id):
1347
+ continue
1348
+ family_prefix, _, member_suffix = each_node.id.partition("_")
1349
+ if not member_suffix:
1350
+ continue
1351
+ members_by_family.setdefault(family_prefix, set()).add(member_suffix.lower())
1352
+ return members_by_family
1353
+
1354
+
1355
+ def _eligible_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1356
+ return {
1357
+ each_family: each_members
1358
+ for each_family, each_members in _referenced_constant_families(parsed_tree).items()
1359
+ if len(each_members) >= MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK
1360
+ }
1361
+
1362
+
1363
+ def _docstring_snake_case_tokens(docstring_text: str) -> set[str]:
1364
+ return set(DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN.findall(docstring_text))
1365
+
1366
+
1367
+ def _docstring_stated_outcome_cardinals(docstring_text: str) -> set[int]:
1368
+ return {
1369
+ ALL_CARDINAL_NUMBER_WORD_VALUES[each_match.group(1).lower()]
1370
+ for each_match in DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN.finditer(docstring_text)
1371
+ }
1372
+
1373
+
1374
+ def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[int, str]]:
1375
+ docstrings_with_line: list[tuple[int, str]] = []
1376
+ module_docstring = ast.get_docstring(parsed_tree) or ""
1377
+ if module_docstring:
1378
+ docstrings_with_line.append((1, module_docstring))
1379
+ for each_node in ast.walk(parsed_tree):
1380
+ if not isinstance(
1381
+ each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
1382
+ ):
1383
+ continue
1384
+ node_docstring = ast.get_docstring(each_node) or ""
1385
+ if node_docstring:
1386
+ docstrings_with_line.append((each_node.lineno, node_docstring))
1387
+ return docstrings_with_line
1388
+
1389
+
1390
+ def check_docstring_cardinal_count_matches_constant_family(
1391
+ content: str, file_path: str
1392
+ ) -> list[str]:
1393
+ """Flag a docstring cardinal count that under-names a referenced constant family.
1394
+
1395
+ The drift this catches: a docstring states a cardinal count of an outcome
1396
+ family (``Covers the four outcome branches: ...``) and enumerates that
1397
+ family's members in prose, while the module references more members of the
1398
+ same ``UPPER_SNAKE`` constant family than the count names. The prose
1399
+ under-describes the code — a reader trusts the count and the enumeration to
1400
+ be the full set, but the module imports and exercises an outcome the summary
1401
+ omits. The check binds only when the docstring states a cardinal beside an
1402
+ outcome noun, names two or more members of one referenced family, leaves at
1403
+ least one referenced family member unnamed, and the family member count
1404
+ differs from every stated cardinal; a complete enumeration, a count that
1405
+ matches the family, and a single passing mention are all left alone. This is
1406
+ the deterministic cardinal-count slice of Category O6
1407
+ docstring-prose-vs-implementation drift. The check runs on test files too,
1408
+ since the drift class lives in a test-module summary.
1409
+
1410
+ Args:
1411
+ content: The source text to inspect.
1412
+ file_path: The path the source will be written to, used for exemptions.
1413
+
1414
+ Returns:
1415
+ One issue per docstring whose cardinal count and enumeration omit a
1416
+ referenced constant-family member, capped at the module limit.
1417
+ """
1418
+ try:
1419
+ parsed_tree = ast.parse(content)
1420
+ except SyntaxError:
1421
+ return []
1422
+ eligible_families = _eligible_constant_families(parsed_tree)
1423
+ if not eligible_families:
1424
+ return []
1425
+ issues: list[str] = []
1426
+ for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
1427
+ stated_cardinals = _docstring_stated_outcome_cardinals(each_docstring)
1428
+ if not stated_cardinals:
1429
+ continue
1430
+ docstring_tokens = _docstring_snake_case_tokens(each_docstring)
1431
+ if not docstring_tokens:
1432
+ continue
1433
+ for each_family in sorted(eligible_families):
1434
+ family_members = eligible_families[each_family]
1435
+ named_members = docstring_tokens & family_members
1436
+ if len(named_members) < MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK:
1437
+ continue
1438
+ omitted_members = family_members - docstring_tokens
1439
+ if not omitted_members:
1440
+ continue
1441
+ if len(family_members) in stated_cardinals:
1442
+ continue
1443
+ issues.append(
1444
+ f"Line {each_line}: docstring names {sorted(stated_cardinals)} as the "
1445
+ f"{each_family}_ count but the module references {len(family_members)} "
1446
+ f"{each_family}_ constants — omits {sorted(omitted_members)}; match the "
1447
+ "count and enumeration to the referenced family "
1448
+ "(Category O6 docstring-vs-implementation drift)"
1449
+ )
1450
+ if len(issues) >= MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES:
1451
+ return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
1452
+ return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
1453
+
1454
+
1114
1455
  def _called_callee_name(statement: ast.stmt) -> str:
1115
1456
  candidate_expression: ast.expr | None = None
1116
1457
  if isinstance(statement, ast.Expr):
@@ -1458,3 +1799,392 @@ def check_docstring_names_undefined_constant(content: str, file_path: str) -> li
1458
1799
  if len(issues) >= MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES:
1459
1800
  return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
1460
1801
  return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
1802
+
1803
+
1804
+ def _args_entry_prose_by_argument(docstring_text: str) -> dict[str, str]:
1805
+ docstring_lines = docstring_text.splitlines()
1806
+ args_section_index = _find_args_section_index(docstring_lines)
1807
+ if args_section_index is None:
1808
+ return {}
1809
+ prose_by_argument: dict[str, list[str]] = {}
1810
+ current_argument_name: str | None = None
1811
+ entry_indent: int | None = None
1812
+ for each_line in docstring_lines[args_section_index + 1:]:
1813
+ stripped_line = each_line.strip()
1814
+ if not stripped_line:
1815
+ continue
1816
+ if _is_docstring_terminating_section_header(stripped_line):
1817
+ break
1818
+ current_indent = len(each_line) - len(each_line.lstrip())
1819
+ if current_indent == 0:
1820
+ break
1821
+ if entry_indent is None:
1822
+ entry_indent = current_indent
1823
+ if current_indent <= entry_indent:
1824
+ entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
1825
+ if entry_match is not None:
1826
+ current_argument_name = entry_match.group(1)
1827
+ prose_by_argument[current_argument_name] = [stripped_line]
1828
+ continue
1829
+ if current_argument_name is not None:
1830
+ prose_by_argument[current_argument_name].append(stripped_line)
1831
+ return {
1832
+ each_name: " ".join(each_lines).lower()
1833
+ for each_name, each_lines in prose_by_argument.items()
1834
+ }
1835
+
1836
+
1837
+ def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
1838
+ if any(
1839
+ each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES
1840
+ ):
1841
+ return False
1842
+ return any(
1843
+ each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES
1844
+ )
1845
+
1846
+
1847
+ def _call_node_builds_two_argument_range(call_node: ast.Call) -> bool:
1848
+ callee = call_node.func
1849
+ return (
1850
+ isinstance(callee, ast.Name)
1851
+ and callee.id == "range"
1852
+ and len(call_node.args) >= MINIMUM_TOKENS_FOR_DISPATCH_CALLEE
1853
+ )
1854
+
1855
+
1856
+ def _function_body_scopes_a_span_by_intersection(
1857
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
1858
+ ) -> bool:
1859
+ builds_range_span = False
1860
+ calls_span_scoper = False
1861
+ for each_node in ast.walk(function_node):
1862
+ if not isinstance(each_node, ast.Call):
1863
+ continue
1864
+ if _call_node_builds_two_argument_range(each_node):
1865
+ builds_range_span = True
1866
+ if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
1867
+ calls_span_scoper = True
1868
+ return builds_range_span and calls_span_scoper
1869
+
1870
+
1871
+ def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str) -> list[str]:
1872
+ """Flag a docstring Args entry scoping a finding to one line over a span body.
1873
+
1874
+ The drift this catches: an ``Args:`` entry whose prose says a finding blocks
1875
+ "only when its block-anchor line is among the changed lines" (a single named
1876
+ line) while the function body builds a ``range(...)`` span over the finding's
1877
+ source lines and routes it through a span-intersection scoper that blocks when
1878
+ ANY line of the span is among the changed lines. The Args sentence claims a
1879
+ narrower single-line scope than the body applies, so an edit touching a
1880
+ non-anchor line of the span still blocks — contradicting the Args entry. This
1881
+ is the deterministic slice of Category O6 docstring-vs-implementation drift
1882
+ for an Args single-line scope claim disagreeing with a span-intersection body.
1883
+
1884
+ An entry is left alone when its prose says "any line of" / "any line in" its
1885
+ span, since that wording matches the span body. The body is judged a
1886
+ span-intersection scoper only when it both builds a two-argument ``range(...)``
1887
+ and calls a known span scoper, so a body that scopes by a single line never
1888
+ trips the check. Hook infrastructure is in scope here — the import-sort gate
1889
+ that carries this drift class is itself a hook — and test files are exempt.
1890
+
1891
+ Args:
1892
+ content: The source text to inspect.
1893
+ file_path: The path the source will be written to, used for exemptions.
1894
+
1895
+ Returns:
1896
+ One issue per function whose Args single-line scope claim disagrees with
1897
+ its span-intersection body, capped at the module limit.
1898
+ """
1899
+ if is_test_file(file_path):
1900
+ return []
1901
+ try:
1902
+ parsed_tree = ast.parse(content)
1903
+ except SyntaxError:
1904
+ return []
1905
+ issues: list[str] = []
1906
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1907
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1908
+ continue
1909
+ if _function_has_exempt_decorator(each_node):
1910
+ continue
1911
+ docstring_text = _function_docstring_text(each_node)
1912
+ if not docstring_text:
1913
+ continue
1914
+ if not _function_body_scopes_a_span_by_intersection(each_node):
1915
+ continue
1916
+ for each_argument, each_prose in _args_entry_prose_by_argument(docstring_text).items():
1917
+ if not _argument_prose_scopes_a_single_line(each_prose):
1918
+ continue
1919
+ issues.append(
1920
+ f"Line {each_node.lineno}: {each_node.name}() Args entry '{each_argument}' "
1921
+ "scopes a finding to a single line ('anchor line is among the changed lines') "
1922
+ "while the body builds a range() span and scopes by span intersection — an "
1923
+ "edit touching any non-anchor line of the span still blocks; restate the Args "
1924
+ "entry as 'any line of its span is among the changed lines' (Category O6 "
1925
+ "docstring-vs-implementation drift)"
1926
+ )
1927
+ if len(issues) >= MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES:
1928
+ return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
1929
+ return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
1930
+
1931
+
1932
+ def _is_narrative_cut_header(stripped_line: str) -> bool:
1933
+ return (
1934
+ stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
1935
+ or stripped_line in ALL_DOCSTRING_ARGS_SECTION_HEADERS
1936
+ )
1937
+
1938
+
1939
+ def _docstring_narrative_text(docstring_text: str) -> str:
1940
+ narrative_lines: list[str] = []
1941
+ for each_line in docstring_text.splitlines():
1942
+ stripped_line = each_line.strip()
1943
+ if _is_narrative_cut_header(stripped_line):
1944
+ break
1945
+ narrative_lines.append(stripped_line)
1946
+ return " ".join(narrative_lines)
1947
+
1948
+
1949
+ def _sentence_word_count(sentence_text: str) -> int:
1950
+ return sum(
1951
+ 1
1952
+ for each_token in sentence_text.split()
1953
+ if any(each_character.isalnum() for each_character in each_token)
1954
+ )
1955
+
1956
+
1957
+ def _sentence_carries_joiner_marker(sentence_text: str) -> bool:
1958
+ return any(
1959
+ each_marker in sentence_text for each_marker in ALL_DOCSTRING_RUNON_JOINER_MARKERS
1960
+ )
1961
+
1962
+
1963
+ def _runon_sentences(narrative_text: str) -> list[str]:
1964
+ flagged_sentences: list[str] = []
1965
+ for each_sentence in DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN.split(narrative_text):
1966
+ stripped_sentence = each_sentence.strip()
1967
+ if not stripped_sentence:
1968
+ continue
1969
+ if _sentence_word_count(stripped_sentence) <= DOCSTRING_RUNON_SENTENCE_WORD_LIMIT:
1970
+ continue
1971
+ if not _sentence_carries_joiner_marker(stripped_sentence):
1972
+ continue
1973
+ flagged_sentences.append(stripped_sentence)
1974
+ return flagged_sentences
1975
+
1976
+
1977
+ def _documentable_docstring_targets(
1978
+ parsed_tree: ast.Module,
1979
+ ) -> list[tuple[int, str, str]]:
1980
+ documentable_targets: list[tuple[int, str, str]] = []
1981
+ module_docstring = ast.get_docstring(parsed_tree)
1982
+ if module_docstring and parsed_tree.body:
1983
+ documentable_targets.append((parsed_tree.body[0].lineno, "module", module_docstring))
1984
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
1985
+ if isinstance(each_node, ast.ClassDef):
1986
+ class_docstring = ast.get_docstring(each_node)
1987
+ if class_docstring:
1988
+ documentable_targets.append((each_node.lineno, each_node.name, class_docstring))
1989
+ continue
1990
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1991
+ continue
1992
+ if _function_is_private_or_dunder(each_node.name):
1993
+ continue
1994
+ if _function_has_exempt_decorator(each_node):
1995
+ continue
1996
+ function_docstring = _function_docstring_text(each_node)
1997
+ if function_docstring:
1998
+ documentable_targets.append(
1999
+ (each_node.lineno, f"{each_node.name}()", function_docstring)
2000
+ )
2001
+ return documentable_targets
2002
+
2003
+
2004
+ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
2005
+ """Flag a docstring narrative sentence that reads as a dense run-on wall.
2006
+
2007
+ A readable docstring breaks its narrative into short sentences a general
2008
+ developer follows on the first read. The one mechanical mark of a wall is a
2009
+ single sentence that runs past the word limit while chaining clauses with an
2010
+ em-dash or a semicolon. This check inspects the narrative prose of module,
2011
+ class, and public-function docstrings — the text before the first structured
2012
+ section header (``Args:``, ``Arguments:``, ``Returns:``, ``Yields:``,
2013
+ ``Raises:``, ``Note:``, ``Notes:``, ``Example:``, or ``Examples:``) — and
2014
+ reports a sentence that is both over the word limit and joined by one of those
2015
+ marks.
2016
+ Whether the prose paints a concrete, illustrative picture is judgment the
2017
+ plain-illustrative-docstrings audit lane carries; this gate catches only the
2018
+ run-on mark.
2019
+
2020
+ Args:
2021
+ content: The source text to inspect.
2022
+ file_path: The path the source will be written to, used for exemptions.
2023
+
2024
+ Returns:
2025
+ One issue per docstring whose narrative carries a run-on sentence, capped
2026
+ at the module limit.
2027
+ """
2028
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
2029
+ return []
2030
+ try:
2031
+ parsed_tree = ast.parse(content)
2032
+ except SyntaxError:
2033
+ return []
2034
+ issues: list[str] = []
2035
+ for each_line_number, each_label, each_docstring in _documentable_docstring_targets(
2036
+ parsed_tree
2037
+ ):
2038
+ flagged_sentences = _runon_sentences(_docstring_narrative_text(each_docstring))
2039
+ if not flagged_sentences:
2040
+ continue
2041
+ run_on_word_count = _sentence_word_count(flagged_sentences[0])
2042
+ issues.append(
2043
+ f"Line {each_line_number}: {each_label} docstring carries a {run_on_word_count}-word "
2044
+ "run-on sentence — break the narrative into short, illustrative sentences a general "
2045
+ "developer reads in one pass (plain-illustrative-docstrings)"
2046
+ )
2047
+ if len(issues) >= MAX_DOCSTRING_RUNON_SENTENCE_ISSUES:
2048
+ break
2049
+ return issues[:MAX_DOCSTRING_RUNON_SENTENCE_ISSUES]
2050
+
2051
+
2052
+ def _raises_section_text(docstring_text: str) -> str:
2053
+ docstring_lines = docstring_text.splitlines()
2054
+ raises_section_lines: list[str] = []
2055
+ inside_raises_section = False
2056
+ for each_line in docstring_lines:
2057
+ stripped_line = each_line.strip()
2058
+ if stripped_line == "Raises:":
2059
+ inside_raises_section = True
2060
+ continue
2061
+ if not inside_raises_section:
2062
+ continue
2063
+ if _is_docstring_terminating_section_header(stripped_line):
2064
+ break
2065
+ raises_section_lines.append(stripped_line)
2066
+ return " ".join(raises_section_lines)
2067
+
2068
+
2069
+ def _call_argument_by_keyword_or_position(
2070
+ call_node: ast.Call, keyword_name: str, positional_index: int
2071
+ ) -> ast.expr | None:
2072
+ for each_keyword in call_node.keywords:
2073
+ if each_keyword.arg == keyword_name:
2074
+ return each_keyword.value
2075
+ if positional_index >= len(call_node.args):
2076
+ return None
2077
+ if any(
2078
+ isinstance(each_argument, ast.Starred)
2079
+ for each_argument in call_node.args[: positional_index + 1]
2080
+ ):
2081
+ return None
2082
+ return call_node.args[positional_index]
2083
+
2084
+
2085
+ def _call_opens_zipfile_write_mode_writer(call_node: ast.Call) -> bool:
2086
+ callee = call_node.func
2087
+ if isinstance(callee, ast.Attribute):
2088
+ callee_name = callee.attr
2089
+ elif isinstance(callee, ast.Name):
2090
+ callee_name = callee.id
2091
+ else:
2092
+ return False
2093
+ if callee_name != ZIPFILE_WRITER_CLASS_NAME:
2094
+ return False
2095
+ mode_argument = _call_argument_by_keyword_or_position(
2096
+ call_node, ZIPFILE_MODE_KEYWORD, ZIPFILE_MODE_POSITIONAL_INDEX
2097
+ )
2098
+ return (
2099
+ isinstance(mode_argument, ast.Constant)
2100
+ and mode_argument.value in ALL_ZIPFILE_WRITE_MODE_VALUES
2101
+ )
2102
+
2103
+
2104
+ def _zipfile_writer_forbids_zip64(call_node: ast.Call) -> bool:
2105
+ allow_zip64_argument = _call_argument_by_keyword_or_position(
2106
+ call_node, ZIPFILE_ALLOW_ZIP64_KEYWORD, ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX
2107
+ )
2108
+ return (
2109
+ isinstance(allow_zip64_argument, ast.Constant)
2110
+ and allow_zip64_argument.value is False
2111
+ )
2112
+
2113
+
2114
+ def _function_documents_unraisable_largezipfile(
2115
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
2116
+ docstring_text: str,
2117
+ ) -> bool:
2118
+ if DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME not in _raises_section_text(docstring_text):
2119
+ return False
2120
+ write_mode_writers = [
2121
+ each_descendant
2122
+ for each_descendant in _walk_skipping_nested_functions(function_node)
2123
+ if isinstance(each_descendant, ast.Call)
2124
+ and _call_opens_zipfile_write_mode_writer(each_descendant)
2125
+ ]
2126
+ if not write_mode_writers:
2127
+ return False
2128
+ return not any(
2129
+ _zipfile_writer_forbids_zip64(each_writer) for each_writer in write_mode_writers
2130
+ )
2131
+
2132
+
2133
+ def check_docstring_raises_unraisable_largezipfile(
2134
+ content: str, file_path: str
2135
+ ) -> list[str]:
2136
+ """Flag a Raises clause naming LargeZipFile over a default-ZIP64 writer.
2137
+
2138
+ The drift this catches: a function whose docstring Raises clause lists
2139
+ ``zipfile.LargeZipFile`` while the function opens its ``zipfile.ZipFile``
2140
+ writer in a write mode (``w``/``a``/``x``) with ``allowZip64`` left at its
2141
+ default of True. The stdlib raises ``LargeZipFile`` only when an entry needs
2142
+ ZIP64 AND ``allowZip64`` is False; with ZIP64 permitted the writer
2143
+ transparently uses it and never raises. The Raises entry then documents an
2144
+ exception the body cannot produce, so a caller guarding ``LargeZipFile`` on
2145
+ the strength of the docstring guards an unreachable path. This is the
2146
+ deterministic slice of Category O6 docstring-prose-vs-implementation drift
2147
+ where a writer opened with default ZIP64 disagrees with a LargeZipFile
2148
+ Raises clause.
2149
+
2150
+ The check binds only when the function opens at least one write-mode
2151
+ ``ZipFile`` and every such writer permits ZIP64, so a function that forbids
2152
+ ZIP64 on any writer (``allowZip64=False``, by keyword or position), a
2153
+ read-only open, and a function that opens no writer — where the exception may
2154
+ propagate from a callee — are all left alone.
2155
+
2156
+ Args:
2157
+ content: The source text to inspect.
2158
+ file_path: The path the source will be written to, used for exemptions.
2159
+
2160
+ Returns:
2161
+ One issue per function whose LargeZipFile Raises clause names an
2162
+ unreachable exception, capped at the module limit.
2163
+ """
2164
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
2165
+ return []
2166
+ try:
2167
+ parsed_tree = ast.parse(content)
2168
+ except SyntaxError:
2169
+ return []
2170
+ issues: list[str] = []
2171
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
2172
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2173
+ continue
2174
+ if _function_has_exempt_decorator(each_node):
2175
+ continue
2176
+ docstring_text = _function_docstring_text(each_node)
2177
+ if not docstring_text:
2178
+ continue
2179
+ if not _function_documents_unraisable_largezipfile(each_node, docstring_text):
2180
+ continue
2181
+ issues.append(
2182
+ f"Line {each_node.lineno}: {each_node.name}() docstring Raises lists "
2183
+ "zipfile.LargeZipFile, but the function opens its ZipFile writer with ZIP64 "
2184
+ "permitted (allowZip64 defaults to True) — LargeZipFile raises only when "
2185
+ "allowZip64 is False, so drop the entry or pass allowZip64=False "
2186
+ "(Category O6 docstring-vs-implementation drift)"
2187
+ )
2188
+ if len(issues) >= MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES:
2189
+ break
2190
+ return issues[:MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES]