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.
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
- package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
- package/hooks/blocking/code_rules_docstrings.py +730 -0
- package/hooks/blocking/code_rules_enforcer.py +39 -0
- package/hooks/blocking/code_rules_paired_test.py +299 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
- package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +51 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/paired-test-coverage.md +28 -0
- 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]
|