claude-dev-env 1.77.0 → 1.79.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 (46) hide show
  1. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  2. package/bin/install.mjs +1 -0
  3. package/bin/install.test.mjs +3 -2
  4. package/hooks/blocking/CLAUDE.md +5 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
  6. package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
  7. package/hooks/blocking/code_rules_docstrings.py +951 -6
  8. package/hooks/blocking/code_rules_enforcer.py +64 -0
  9. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  10. package/hooks/blocking/code_rules_paired_test.py +517 -0
  11. package/hooks/blocking/code_rules_string_magic.py +71 -1
  12. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  13. package/hooks/blocking/convergence_gate_blocker.py +24 -15
  14. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  15. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  16. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
  17. package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  24. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  25. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +339 -0
  26. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  27. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  28. package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
  29. package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
  30. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  31. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  32. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  33. package/hooks/hooks_constants/CLAUDE.md +2 -0
  34. package/hooks/hooks_constants/blocking_check_limits.py +102 -0
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
  36. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  37. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  38. package/hooks/hooks_constants/paired_test_coverage_constants.py +35 -0
  39. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +2 -0
  42. package/rules/docstring-prose-matches-implementation.md +56 -53
  43. package/rules/env-var-table-code-drift.md +24 -0
  44. package/rules/file-global-constants.md +2 -2
  45. package/rules/package-inventory-stale-entry.md +4 -4
  46. package/rules/paired-test-coverage.md +35 -0
@@ -22,6 +22,8 @@ from code_rules_shared import ( # noqa: E402
22
22
  )
23
23
 
24
24
  from hooks_constants.blocking_check_limits import ( # noqa: E402
25
+ ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS,
26
+ ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES,
25
27
  ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
26
28
  ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
27
29
  ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
@@ -30,41 +32,70 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
30
32
  ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
31
33
  ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
32
34
  ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
35
+ ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES,
33
36
  ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
37
+ ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES,
38
+ ALL_DOCSTRING_RUN_MODE_PHRASES,
39
+ ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS,
34
40
  ALL_DOCSTRING_RUNON_JOINER_MARKERS,
41
+ ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
42
+ ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
43
+ ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
35
44
  ALL_GENERIC_CHECK_NAME_TOKENS,
45
+ ALL_LENGTH_CONSTANT_NAME_SUFFIXES,
46
+ ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES,
36
47
  ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
48
+ ALL_PATH_METADATA_ACCESS_METHOD_NAMES,
49
+ ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES,
50
+ ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES,
51
+ ALL_ZIPFILE_WRITE_MODE_VALUES,
37
52
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
53
+ DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME,
38
54
  DOCSTRING_REFERENCE_MARKER_WINDOW,
39
55
  DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
40
56
  DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
41
57
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
58
+ LENGTH_CONFIG_SUBDIRECTORY_NAME,
59
+ LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT,
42
60
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
61
+ MAX_COMPANION_MODULE_RESOLUTION_DEPTH,
43
62
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
63
+ MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
44
64
  MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
45
65
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
66
+ MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES,
46
67
  MAX_DOCSTRING_FORMAT_ISSUES,
47
68
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
69
+ MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES,
48
70
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
71
+ MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES,
72
+ MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES,
73
+ MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
49
74
  MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
50
- ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
51
- ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
52
- ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
53
- MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
54
75
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
55
- MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
56
76
  MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
57
77
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
58
78
  MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
79
+ MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES,
59
80
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
81
+ MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES,
60
82
  MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
61
83
  MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
62
84
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
85
+ MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
63
86
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
64
87
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
65
88
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
66
89
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
90
+ MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
91
+ PYTHON_MODULE_FILE_SUFFIX,
67
92
  SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
93
+ WORD_BOUNDARY_REGEX,
94
+ ZIPFILE_ALLOW_ZIP64_KEYWORD,
95
+ ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX,
96
+ ZIPFILE_MODE_KEYWORD,
97
+ ZIPFILE_MODE_POSITIONAL_INDEX,
98
+ ZIPFILE_WRITER_CLASS_NAME,
68
99
  )
69
100
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
70
101
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
@@ -355,6 +386,71 @@ def check_docstring_args_match_signature(content: str, file_path: str) -> list[s
355
386
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
356
387
 
357
388
 
389
+ def check_docstring_documents_unreferenced_parameter(
390
+ content: str, file_path: str
391
+ ) -> list[str]:
392
+ """Flag a documented Args parameter the function body never references.
393
+
394
+ A parameter that appears in the ``Args:`` block but is named nowhere in the
395
+ body is dead: the function does not read it, yet the docstring describes
396
+ behavior keyed to it. The common shape is a flag that a caller wired in,
397
+ then moved the real logic up a level — the parameter and its Args line stay
398
+ behind, claiming a behavior the body does not implement. Both the unused
399
+ parameter and the stale Args claim drift together, so the gate catches them
400
+ as one finding.
401
+
402
+ Functions whose signature accepts ``**kwargs`` are skipped, because a
403
+ documented name may be a keyword key consumed through the kwargs mapping
404
+ rather than a named parameter. Private, dunder, abstract, and stub-bodied
405
+ functions are skipped, since a parameter declared for interface conformance
406
+ legitimately goes unread.
407
+
408
+ Args:
409
+ content: The source text to inspect.
410
+ file_path: The path the source will be written to, used for exemptions.
411
+
412
+ Returns:
413
+ One issue per documented-but-unreferenced parameter, capped at the
414
+ module limit.
415
+ """
416
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
417
+ return []
418
+ try:
419
+ parsed_tree = ast.parse(content)
420
+ except SyntaxError:
421
+ return []
422
+ issues: list[str] = []
423
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
424
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
425
+ continue
426
+ if _function_is_private_or_dunder(each_node.name):
427
+ continue
428
+ if _function_has_exempt_decorator(each_node):
429
+ continue
430
+ if _function_body_line_count(each_node) <= DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT:
431
+ continue
432
+ if each_node.args.kwarg is not None:
433
+ continue
434
+ documented_names = _documented_argument_names(_function_docstring_text(each_node))
435
+ if not documented_names:
436
+ continue
437
+ real_names = _signature_parameter_names(each_node)
438
+ referenced_names = _names_referenced_in_function(each_node)
439
+ for each_documented_name in documented_names:
440
+ if each_documented_name not in real_names:
441
+ continue
442
+ if each_documented_name in referenced_names:
443
+ continue
444
+ issues.append(
445
+ f"Line {each_node.lineno}: {each_node.name}() docstring Args: documents "
446
+ f"'{each_documented_name}' but the body never references it - drop the "
447
+ "unused parameter and its Args line, or use it"
448
+ )
449
+ if len(issues) >= MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES:
450
+ return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
451
+ return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
452
+
453
+
358
454
  def _callee_expression_name(expression: ast.expr) -> str:
359
455
  if isinstance(expression, ast.Name):
360
456
  return expression.id
@@ -777,6 +873,92 @@ def check_docstring_unguarded_malformed_payload_claim(
777
873
  return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
778
874
 
779
875
 
876
+ def _docstring_claims_no_network_access(docstring_text: str) -> str:
877
+ collapsed_docstring = " ".join(docstring_text.lower().split())
878
+ for each_phrase in ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES:
879
+ if each_phrase in collapsed_docstring:
880
+ return each_phrase
881
+ return ""
882
+
883
+
884
+ def _function_performs_path_metadata_access(
885
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
886
+ ) -> str:
887
+ for each_descendant in ast.walk(function_node):
888
+ if not isinstance(each_descendant, ast.Call):
889
+ continue
890
+ callee = each_descendant.func
891
+ if (
892
+ isinstance(callee, ast.Attribute)
893
+ and callee.attr in ALL_PATH_METADATA_ACCESS_METHOD_NAMES
894
+ ):
895
+ return callee.attr
896
+ return ""
897
+
898
+
899
+ def check_docstring_no_network_claim_with_metadata_access(
900
+ content: str, file_path: str
901
+ ) -> list[str]:
902
+ """Flag a docstring promising no network touch while the body stats a path.
903
+
904
+ Picture a cache helper whose docstring says it returns the warm cache
905
+ "without touching the network." A reader trusts that a cache hit costs
906
+ nothing on the share. But the body calls ``share_path.is_file()`` and
907
+ ``share_path.stat().st_size`` before it can return the cache — and on a
908
+ network share each of those metadata calls is a round-trip over the wire.
909
+ The promise drifts: every cache hit still pays two network stats the
910
+ docstring swore it avoided.
911
+
912
+ This is the deterministic slice of Category O6 (docstring prose versus
913
+ implementation drift) for a no-network claim: a function docstring that
914
+ states the path returns ``without touching the network`` (or a sibling
915
+ no-network phrase) while the body calls ``is_file``, ``is_dir``,
916
+ ``exists``, ``stat``, or ``lstat``. Either reword the claim to state that
917
+ the path is stat-checked on every call, or short-circuit to the cached
918
+ path before the share is touched.
919
+
920
+ Args:
921
+ content: The source text to inspect.
922
+ file_path: The path the source will be written to, used for exemptions.
923
+
924
+ Returns:
925
+ One issue per function whose no-network claim coexists with a
926
+ path-metadata call, capped at the module limit.
927
+ """
928
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
929
+ return []
930
+ try:
931
+ parsed_tree = ast.parse(content)
932
+ except SyntaxError:
933
+ return []
934
+ issues: list[str] = []
935
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
936
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
937
+ continue
938
+ if _function_has_exempt_decorator(each_node):
939
+ continue
940
+ docstring_text = _function_docstring_text(each_node)
941
+ if not docstring_text:
942
+ continue
943
+ matched_phrase = _docstring_claims_no_network_access(docstring_text)
944
+ if not matched_phrase:
945
+ continue
946
+ accessed_method = _function_performs_path_metadata_access(each_node)
947
+ if not accessed_method:
948
+ continue
949
+ issues.append(
950
+ f"Line {each_node.lineno}: {each_node.name}() docstring claims "
951
+ f"'{matched_phrase}' but the body calls .{accessed_method}() on a path — "
952
+ "a metadata stat is itself a network touch on a share, so the no-network "
953
+ "claim drifts; reword the docstring to state the path is stat-checked on "
954
+ "every call, or short-circuit before touching the share "
955
+ "(Category O6 docstring-vs-implementation drift)"
956
+ )
957
+ if len(issues) >= MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES:
958
+ break
959
+ return issues[:MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES]
960
+
961
+
780
962
  def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
781
963
  collapsed_docstring = " ".join(module_docstring.lower().split())
782
964
  for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
@@ -914,6 +1096,105 @@ def check_module_docstring_names_public_checks(content: str, file_path: str) ->
914
1096
  return issues[:MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES]
915
1097
 
916
1098
 
1099
+ def _module_level_upper_snake_constant_names(parsed_tree: ast.Module) -> list[str]:
1100
+ constant_names: list[str] = []
1101
+ for each_statement in parsed_tree.body:
1102
+ target_nodes: list[ast.expr] = []
1103
+ if isinstance(each_statement, ast.Assign):
1104
+ target_nodes = list(each_statement.targets)
1105
+ elif isinstance(each_statement, ast.AnnAssign):
1106
+ target_nodes = [each_statement.target]
1107
+ for each_target in target_nodes:
1108
+ if (
1109
+ isinstance(each_target, ast.Name)
1110
+ and ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_target.id)
1111
+ ):
1112
+ constant_names.append(each_target.id)
1113
+ return constant_names
1114
+
1115
+
1116
+ def _constant_name_data_schema_marker(constant_name: str) -> str:
1117
+ for each_marker in ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS:
1118
+ if each_marker in constant_name:
1119
+ return each_marker
1120
+ return ""
1121
+
1122
+
1123
+ def _module_data_schema_constant_names(parsed_tree: ast.Module) -> list[str]:
1124
+ return [
1125
+ each_name
1126
+ for each_name in _module_level_upper_snake_constant_names(parsed_tree)
1127
+ if _constant_name_data_schema_marker(each_name)
1128
+ ]
1129
+
1130
+
1131
+ def _module_docstring_claims_user_facing_text_scope(module_docstring: str) -> bool:
1132
+ lowered_docstring = module_docstring.lower()
1133
+ return any(
1134
+ each_phrase in lowered_docstring
1135
+ for each_phrase in ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES
1136
+ )
1137
+
1138
+
1139
+ def _module_docstring_acknowledges_data_schema_scope(module_docstring: str) -> bool:
1140
+ lowered_docstring = module_docstring.lower()
1141
+ return any(
1142
+ each_phrase in lowered_docstring
1143
+ for each_phrase in ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES
1144
+ )
1145
+
1146
+
1147
+ def check_module_docstring_scope_omits_data_schema_constants(
1148
+ content: str, file_path: str
1149
+ ) -> list[str]:
1150
+ """Flag a user-facing-text module docstring that omits data-schema constants.
1151
+
1152
+ A module whose one-line docstring scopes its contents to user-facing text
1153
+ claims a strings-only surface, and a reader trusts that line to map the whole
1154
+ module. The drift this catches is a summary such as "User-facing strings: CLI
1155
+ flag names, help text, and log messages". The body below it also defines
1156
+ serialization field keys, run-metadata schema keys, and runtime config. The
1157
+ summary then under-describes the module, the Category O module-responsibility
1158
+ drift the repo flags. A constant counts as a data-schema or runtime-config
1159
+ value when its name carries a ``_FIELD_``, ``_KEY_``, ``_SCHEMA_``,
1160
+ ``_ENCODING``, or ``_FORMAT_STRING`` marker. The check fires only when the
1161
+ docstring claims a user-facing-text scope and acknowledges no data-schema or
1162
+ runtime-config category. A docstring that already names "field keys", "schema",
1163
+ or "runtime config" passes, so broadening the summary clears the gate.
1164
+
1165
+ Args:
1166
+ content: The source text to inspect.
1167
+ file_path: The path the source will be written to, used for exemptions.
1168
+
1169
+ Returns:
1170
+ One issue naming the unacknowledged data-schema constants, capped at the
1171
+ module limit.
1172
+ """
1173
+ if is_test_file(file_path):
1174
+ return []
1175
+ try:
1176
+ parsed_tree = ast.parse(content)
1177
+ except SyntaxError:
1178
+ return []
1179
+ module_docstring = ast.get_docstring(parsed_tree) or ""
1180
+ if not _module_docstring_claims_user_facing_text_scope(module_docstring):
1181
+ return []
1182
+ if _module_docstring_acknowledges_data_schema_scope(module_docstring):
1183
+ return []
1184
+ data_schema_constant_names = _module_data_schema_constant_names(parsed_tree)
1185
+ if not data_schema_constant_names:
1186
+ return []
1187
+ sampled_names = ", ".join(
1188
+ data_schema_constant_names[:MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT]
1189
+ )
1190
+ return [
1191
+ "Line 1: module docstring scopes the module to user-facing text but the module "
1192
+ f"defines data-schema or runtime-config constants ({sampled_names}) the summary "
1193
+ "never names — broaden the summary to name the data-schema keys and "
1194
+ "runtime-config constants it holds (Category O module-responsibility drift)"
1195
+ ][:MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES]
1196
+
1197
+
917
1198
  def _module_string_tuple_members(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
918
1199
  members_by_constant: dict[str, frozenset[str]] = {}
919
1200
  for each_statement in parsed_tree.body:
@@ -1024,6 +1305,203 @@ def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> lis
1024
1305
  return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
1025
1306
 
1026
1307
 
1308
+ def _known_glyph_marker_members(
1309
+ sequence_node: ast.Tuple | ast.List,
1310
+ ) -> frozenset[str] | None:
1311
+ normalized_glyphs: set[str] = set()
1312
+ for each_element in sequence_node.elts:
1313
+ if not (
1314
+ isinstance(each_element, ast.Constant)
1315
+ and isinstance(each_element.value, str)
1316
+ ):
1317
+ return None
1318
+ normalized_glyph = each_element.value.strip()
1319
+ if normalized_glyph not in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES:
1320
+ return None
1321
+ normalized_glyphs.add(normalized_glyph)
1322
+ return frozenset(normalized_glyphs)
1323
+
1324
+
1325
+ def _assignment_targets_and_sequence(
1326
+ statement: ast.stmt,
1327
+ ) -> tuple[list[str], ast.Tuple | ast.List | None]:
1328
+ if isinstance(statement, ast.Assign) and isinstance(
1329
+ statement.value, (ast.Tuple, ast.List)
1330
+ ):
1331
+ plain_target_names = [
1332
+ each_target.id
1333
+ for each_target in statement.targets
1334
+ if isinstance(each_target, ast.Name)
1335
+ ]
1336
+ return plain_target_names, statement.value
1337
+ if (
1338
+ isinstance(statement, ast.AnnAssign)
1339
+ and isinstance(statement.target, ast.Name)
1340
+ and isinstance(statement.value, (ast.Tuple, ast.List))
1341
+ ):
1342
+ return [statement.target.id], statement.value
1343
+ return [], None
1344
+
1345
+
1346
+ def _module_glyph_marker_tuples(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
1347
+ glyphs_by_constant: dict[str, frozenset[str]] = {}
1348
+ for each_statement in parsed_tree.body:
1349
+ target_names, sequence_node = _assignment_targets_and_sequence(each_statement)
1350
+ if sequence_node is None:
1351
+ continue
1352
+ marker_glyphs = _known_glyph_marker_members(sequence_node)
1353
+ if marker_glyphs is None:
1354
+ continue
1355
+ if len(marker_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
1356
+ continue
1357
+ for each_target_name in target_names:
1358
+ glyphs_by_constant[each_target_name] = marker_glyphs
1359
+ return glyphs_by_constant
1360
+
1361
+
1362
+ def _companion_module_file(dotted_module: str, file_path: str) -> Path | None:
1363
+ relative_module_path = Path(*dotted_module.split(".")).with_suffix(
1364
+ PYTHON_MODULE_FILE_SUFFIX
1365
+ )
1366
+ file_directory = Path(file_path).parent
1367
+ candidate_roots = [file_directory, *file_directory.parents]
1368
+ for each_root in candidate_roots[:MAX_COMPANION_MODULE_RESOLUTION_DEPTH]:
1369
+ candidate_path = each_root / relative_module_path
1370
+ if candidate_path.is_file():
1371
+ return candidate_path
1372
+ return None
1373
+
1374
+
1375
+ def _companion_glyph_marker_tuples(companion_path: Path) -> dict[str, frozenset[str]]:
1376
+ try:
1377
+ companion_source = companion_path.read_text(encoding="utf-8")
1378
+ companion_tree = ast.parse(companion_source)
1379
+ except (OSError, ValueError, SyntaxError):
1380
+ return {}
1381
+ return _module_glyph_marker_tuples(companion_tree)
1382
+
1383
+
1384
+ def _import_brings_upper_snake_name(import_node: ast.ImportFrom) -> bool:
1385
+ return any(
1386
+ ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_alias.name)
1387
+ for each_alias in import_node.names
1388
+ )
1389
+
1390
+
1391
+ def _imported_glyph_marker_tuples(
1392
+ parsed_tree: ast.Module, file_path: str
1393
+ ) -> dict[str, frozenset[str]]:
1394
+ glyphs_by_imported_name: dict[str, frozenset[str]] = {}
1395
+ for each_statement in parsed_tree.body:
1396
+ if not isinstance(each_statement, ast.ImportFrom):
1397
+ continue
1398
+ if not each_statement.module:
1399
+ continue
1400
+ if not _import_brings_upper_snake_name(each_statement):
1401
+ continue
1402
+ companion_path = _companion_module_file(each_statement.module, file_path)
1403
+ if companion_path is None:
1404
+ continue
1405
+ companion_tuples = _companion_glyph_marker_tuples(companion_path)
1406
+ for each_alias in each_statement.names:
1407
+ if each_alias.name not in companion_tuples:
1408
+ continue
1409
+ imported_name = each_alias.asname or each_alias.name
1410
+ glyphs_by_imported_name[imported_name] = companion_tuples[each_alias.name]
1411
+ return glyphs_by_imported_name
1412
+
1413
+
1414
+ def _docstring_names_mark_glyph(docstring_text: str, normalized_glyph: str) -> bool:
1415
+ lowercased_docstring = docstring_text.lower()
1416
+ for each_name in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES[normalized_glyph]:
1417
+ boundary_wrapped_name = (
1418
+ WORD_BOUNDARY_REGEX + re.escape(each_name) + WORD_BOUNDARY_REGEX
1419
+ )
1420
+ if re.search(boundary_wrapped_name, lowercased_docstring):
1421
+ return True
1422
+ return False
1423
+
1424
+
1425
+ def _marks_named_in_docstring(
1426
+ docstring_text: str, all_marker_glyphs: frozenset[str]
1427
+ ) -> set[str]:
1428
+ return {
1429
+ each_glyph
1430
+ for each_glyph in all_marker_glyphs
1431
+ if _docstring_names_mark_glyph(docstring_text, each_glyph)
1432
+ }
1433
+
1434
+
1435
+ def _text_names_multiple_marks(content_text: str) -> bool:
1436
+ all_known_glyphs = frozenset(ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES)
1437
+ named_glyphs = _marks_named_in_docstring(content_text, all_known_glyphs)
1438
+ return len(named_glyphs) >= MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION
1439
+
1440
+
1441
+ def check_docstring_punctuation_mark_enumeration_coverage(
1442
+ content: str, file_path: str
1443
+ ) -> list[str]:
1444
+ """Flag a docstring that names some marks of a glyph tuple but omits one.
1445
+
1446
+ A module reads a tuple of punctuation-mark glyphs as its detection set. The
1447
+ tuple is defined in the module or imported from a companion module beside it.
1448
+ A docstring then enumerates those marks by their English names. When the
1449
+ prose names a closed set of marks but leaves one the tuple holds unnamed, a
1450
+ reader trusts the enumeration and believes an active mark never triggers the
1451
+ check. This is the shape that appears when a glyph joins the tuple while the
1452
+ prose enumeration stays as it was.
1453
+
1454
+ The check binds only when a docstring names two or more marks of one tuple. A
1455
+ docstring that mentions a single mark, names every mark, or describes
1456
+ unrelated punctuation is left alone. This is the deterministic glyph-prose
1457
+ slice of Category O6 docstring-prose-vs-implementation drift, the companion
1458
+ to check_docstring_tuple_enumeration_match for glyph members named in prose
1459
+ rather than identifier members named in inline code. It covers hook
1460
+ infrastructure, where the affected detection tuples live.
1461
+
1462
+ Args:
1463
+ content: The source text to inspect.
1464
+ file_path: The path the source will be written to, used for exemptions.
1465
+
1466
+ Returns:
1467
+ One issue per docstring whose mark enumeration omits a glyph the tuple it
1468
+ describes holds, capped at the module limit.
1469
+ """
1470
+ if is_strict_test_file(file_path):
1471
+ return []
1472
+ if not _text_names_multiple_marks(content):
1473
+ return []
1474
+ try:
1475
+ parsed_tree = ast.parse(content)
1476
+ except SyntaxError:
1477
+ return []
1478
+ glyphs_by_constant = {
1479
+ **_module_glyph_marker_tuples(parsed_tree),
1480
+ **_imported_glyph_marker_tuples(parsed_tree, file_path),
1481
+ }
1482
+ if not glyphs_by_constant:
1483
+ return []
1484
+ issues: list[str] = []
1485
+ for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
1486
+ for each_constant_name in sorted(glyphs_by_constant):
1487
+ marker_glyphs = glyphs_by_constant[each_constant_name]
1488
+ named_glyphs = _marks_named_in_docstring(each_docstring, marker_glyphs)
1489
+ if len(named_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
1490
+ continue
1491
+ omitted_glyphs = marker_glyphs - named_glyphs
1492
+ if not omitted_glyphs:
1493
+ continue
1494
+ issues.append(
1495
+ f"Line {each_line}: docstring names {sorted(named_glyphs)} from "
1496
+ f"{each_constant_name} but omits {sorted(omitted_glyphs)} — name every "
1497
+ "mark the tuple holds so the enumeration matches the detection set "
1498
+ "(Category O6 docstring-vs-implementation drift)"
1499
+ )
1500
+ if len(issues) >= MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES:
1501
+ return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
1502
+ return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
1503
+
1504
+
1027
1505
  def _returns_section_text(docstring_text: str) -> str:
1028
1506
  docstring_lines = docstring_text.splitlines()
1029
1507
  returns_section_lines: list[str] = []
@@ -1125,6 +1603,223 @@ def check_docstring_returns_plural_cardinality(content: str, file_path: str) ->
1125
1603
  return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1126
1604
 
1127
1605
 
1606
+ def _module_level_length_constant_names(parsed_tree: ast.Module) -> set[str]:
1607
+ all_length_constant_names: set[str] = set()
1608
+ for each_statement in parsed_tree.body:
1609
+ target_names: list[str] = []
1610
+ assigned_value: ast.expr | None = None
1611
+ if isinstance(each_statement, ast.Assign):
1612
+ assigned_value = each_statement.value
1613
+ target_names = [
1614
+ each_target.id
1615
+ for each_target in each_statement.targets
1616
+ if isinstance(each_target, ast.Name)
1617
+ ]
1618
+ elif isinstance(each_statement, ast.AnnAssign) and isinstance(
1619
+ each_statement.target, ast.Name
1620
+ ):
1621
+ assigned_value = each_statement.value
1622
+ target_names = [each_statement.target.id]
1623
+ if not isinstance(assigned_value, ast.Constant) or not isinstance(
1624
+ assigned_value.value, int
1625
+ ):
1626
+ continue
1627
+ for each_name in target_names:
1628
+ if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_name):
1629
+ continue
1630
+ if each_name.endswith(ALL_LENGTH_CONSTANT_NAME_SUFFIXES):
1631
+ all_length_constant_names.add(each_name)
1632
+ return all_length_constant_names
1633
+
1634
+
1635
+ def _superlative_phrase_by_length_constant(
1636
+ parsed_tree: ast.Module, all_length_constant_names: set[str]
1637
+ ) -> tuple[dict[str, str], str]:
1638
+ module_docstring = ast.get_docstring(parsed_tree) or ""
1639
+ phrase_by_constant: dict[str, str] = {}
1640
+ unattributed_phrase = ""
1641
+ for each_sentence in DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN.split(module_docstring):
1642
+ lowered_sentence = each_sentence.lower()
1643
+ sentence_phrase = next(
1644
+ (
1645
+ each_phrase
1646
+ for each_phrase in ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES
1647
+ if each_phrase in lowered_sentence
1648
+ ),
1649
+ "",
1650
+ )
1651
+ if not sentence_phrase:
1652
+ continue
1653
+ named_constants = {
1654
+ each_name
1655
+ for each_name in all_length_constant_names
1656
+ if each_name in each_sentence
1657
+ }
1658
+ if named_constants:
1659
+ for each_name in named_constants:
1660
+ phrase_by_constant.setdefault(each_name, sentence_phrase)
1661
+ elif not unattributed_phrase:
1662
+ unattributed_phrase = sentence_phrase
1663
+ return phrase_by_constant, unattributed_phrase
1664
+
1665
+
1666
+ def _compare_targets_a_length_constant(
1667
+ compare_node: ast.Compare, all_length_constant_names: set[str]
1668
+ ) -> str:
1669
+ operands = [compare_node.left, *compare_node.comparators]
1670
+ has_length_call = any(
1671
+ isinstance(each_operand, ast.Call)
1672
+ and isinstance(each_operand.func, ast.Name)
1673
+ and each_operand.func.id == "len"
1674
+ for each_operand in operands
1675
+ )
1676
+ if not has_length_call:
1677
+ return ""
1678
+ for each_operand in operands:
1679
+ if isinstance(each_operand, ast.Name) and each_operand.id in all_length_constant_names:
1680
+ return each_operand.id
1681
+ return ""
1682
+
1683
+
1684
+ def _length_gate_comparison_kinds_in_tree(
1685
+ parsed_tree: ast.Module, all_length_constant_names: set[str]
1686
+ ) -> dict[str, set[str]]:
1687
+ comparison_kinds_by_constant: dict[str, set[str]] = {}
1688
+ for each_node in ast.walk(parsed_tree):
1689
+ if not isinstance(each_node, ast.Compare):
1690
+ continue
1691
+ compared_constant = _compare_targets_a_length_constant(
1692
+ each_node, all_length_constant_names
1693
+ )
1694
+ if not compared_constant:
1695
+ continue
1696
+ for each_operator in each_node.ops:
1697
+ kind = (
1698
+ "equality"
1699
+ if isinstance(each_operator, (ast.Eq, ast.NotEq))
1700
+ else "ordered"
1701
+ )
1702
+ comparison_kinds_by_constant.setdefault(compared_constant, set()).add(kind)
1703
+ return comparison_kinds_by_constant
1704
+
1705
+
1706
+ def _package_scan_python_files(file_path: str) -> list[Path]:
1707
+ file = Path(file_path)
1708
+ scan_root = (
1709
+ file.parent.parent
1710
+ if file.parent.name == LENGTH_CONFIG_SUBDIRECTORY_NAME
1711
+ else file.parent
1712
+ )
1713
+ try:
1714
+ all_python_files = sorted(scan_root.rglob(f"*{PYTHON_MODULE_FILE_SUFFIX}"))
1715
+ except OSError:
1716
+ return []
1717
+ return [
1718
+ each_file
1719
+ for each_file in all_python_files[:LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT]
1720
+ if each_file.resolve() != file.resolve()
1721
+ and not is_test_file(str(each_file))
1722
+ ]
1723
+
1724
+
1725
+ def _exact_length_gate_constants_in_package(
1726
+ file_path: str, all_length_constant_names: set[str]
1727
+ ) -> set[str]:
1728
+ aggregate_kinds: dict[str, set[str]] = {}
1729
+ for each_file in _package_scan_python_files(file_path):
1730
+ try:
1731
+ sibling_tree = ast.parse(each_file.read_text(encoding="utf-8"))
1732
+ except (OSError, SyntaxError, ValueError):
1733
+ continue
1734
+ for each_constant, each_kinds in _length_gate_comparison_kinds_in_tree(
1735
+ sibling_tree, all_length_constant_names
1736
+ ).items():
1737
+ aggregate_kinds.setdefault(each_constant, set()).update(each_kinds)
1738
+ return {
1739
+ each_constant
1740
+ for each_constant, each_kinds in aggregate_kinds.items()
1741
+ if "equality" in each_kinds and "ordered" not in each_kinds
1742
+ }
1743
+
1744
+
1745
+ def check_docstring_length_constant_superlative_vs_exact_gate(
1746
+ content: str, file_path: str
1747
+ ) -> list[str]:
1748
+ """Flag a length-constant docstring that calls an exact gate the "longest" form.
1749
+
1750
+ The drift this catches: a config module defines an integer ``*_LENGTH``
1751
+ constant and its docstring describes that constant with a superlative or
1752
+ range word (``the longest color string the swatch accepts``), while the only
1753
+ code that consumes the constant treats it as an exact-length equality gate
1754
+ (``len(hex_color) != COLOR_AARRGGBB_LENGTH``) — a shorter string is rejected,
1755
+ not accepted at a shorter length. The superlative prose implies a range of
1756
+ accepted lengths the code never allows. The consumer lives in a sibling
1757
+ module, so the check scans the constant module's package tree (its own
1758
+ directory, or its parent package when the module sits in a ``config/``
1759
+ subdirectory) for the comparison; it binds only when a length constant is
1760
+ compared with ``==``/``!=`` against ``len(...)`` somewhere in that tree and
1761
+ never with an ordered operator, so a constant genuinely used as a ceiling
1762
+ (``len(x) <= LIMIT``) is left alone. The superlative phrase is read from the
1763
+ module docstring only, and it is bound to a constant before flagging: a
1764
+ constant is flagged when the docstring sentence carrying the phrase names
1765
+ that constant, or when the phrase sits in a sentence naming no length
1766
+ constant and the docstring never names the constant elsewhere — so a phrase
1767
+ describing one constant (``MAX_NAME_LENGTH is the longest label``) does not
1768
+ flag a different exact-gated constant documented on its own terms. This is
1769
+ the deterministic exact-gate slice of Category O6/O8
1770
+ docstring-vs-implementation drift; the cross-module free-prose variant where
1771
+ the docstring never names the constant stays an audit-lane finding.
1772
+
1773
+ Args:
1774
+ content: The source text to inspect.
1775
+ file_path: The path the source will be written to, used for exemptions
1776
+ and to locate the package tree the consumer lives in.
1777
+
1778
+ Returns:
1779
+ One issue per length constant the package exact-gates while the module
1780
+ docstring binds a superlative phrase to that constant, capped at the
1781
+ module limit.
1782
+ """
1783
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
1784
+ return []
1785
+ try:
1786
+ parsed_tree = ast.parse(content)
1787
+ except SyntaxError:
1788
+ return []
1789
+ all_length_constant_names = _module_level_length_constant_names(parsed_tree)
1790
+ if not all_length_constant_names:
1791
+ return []
1792
+ phrase_by_constant, unattributed_phrase = _superlative_phrase_by_length_constant(
1793
+ parsed_tree, all_length_constant_names
1794
+ )
1795
+ if not phrase_by_constant and not unattributed_phrase:
1796
+ return []
1797
+ module_docstring = ast.get_docstring(parsed_tree) or ""
1798
+ named_length_constants = {
1799
+ each_name for each_name in all_length_constant_names if each_name in module_docstring
1800
+ }
1801
+ exact_gate_constants = _exact_length_gate_constants_in_package(
1802
+ file_path, all_length_constant_names
1803
+ )
1804
+ issues: list[str] = []
1805
+ for each_constant in sorted(exact_gate_constants):
1806
+ bound_phrase = phrase_by_constant.get(each_constant, "")
1807
+ if not bound_phrase and each_constant not in named_length_constants:
1808
+ bound_phrase = unattributed_phrase
1809
+ if not bound_phrase:
1810
+ continue
1811
+ issues.append(
1812
+ f"Line 1: module docstring says '{bound_phrase}' about "
1813
+ f"{each_constant}, but the package compares len(...) against it only "
1814
+ "with ==/!= (an exact-length gate that rejects every other length) — "
1815
+ "state the exact required length, not a longest/maximum range "
1816
+ "(Category O6 docstring-vs-implementation drift)"
1817
+ )
1818
+ if len(issues) >= MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES:
1819
+ return issues[:MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES]
1820
+ return issues[:MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES]
1821
+
1822
+
1128
1823
  def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1129
1824
  members_by_family: dict[str, set[str]] = {}
1130
1825
  for each_node in ast.walk(parsed_tree):
@@ -1796,7 +2491,7 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
1796
2491
  A readable docstring breaks its narrative into short sentences a general
1797
2492
  developer follows on the first read. The one mechanical mark of a wall is a
1798
2493
  single sentence that runs past the word limit while chaining clauses with an
1799
- em-dash or a semicolon. This check inspects the narrative prose of module,
2494
+ em-dash, a double-hyphen, or a semicolon. This check inspects the narrative prose of module,
1800
2495
  class, and public-function docstrings — the text before the first structured
1801
2496
  section header (``Args:``, ``Arguments:``, ``Returns:``, ``Yields:``,
1802
2497
  ``Raises:``, ``Note:``, ``Notes:``, ``Example:``, or ``Examples:``) — and
@@ -1836,3 +2531,253 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
1836
2531
  if len(issues) >= MAX_DOCSTRING_RUNON_SENTENCE_ISSUES:
1837
2532
  break
1838
2533
  return issues[:MAX_DOCSTRING_RUNON_SENTENCE_ISSUES]
2534
+
2535
+
2536
+ def _raises_section_text(docstring_text: str) -> str:
2537
+ docstring_lines = docstring_text.splitlines()
2538
+ raises_section_lines: list[str] = []
2539
+ inside_raises_section = False
2540
+ for each_line in docstring_lines:
2541
+ stripped_line = each_line.strip()
2542
+ if stripped_line == "Raises:":
2543
+ inside_raises_section = True
2544
+ continue
2545
+ if not inside_raises_section:
2546
+ continue
2547
+ if _is_docstring_terminating_section_header(stripped_line):
2548
+ break
2549
+ raises_section_lines.append(stripped_line)
2550
+ return " ".join(raises_section_lines)
2551
+
2552
+
2553
+ def _call_argument_by_keyword_or_position(
2554
+ call_node: ast.Call, keyword_name: str, positional_index: int
2555
+ ) -> ast.expr | None:
2556
+ for each_keyword in call_node.keywords:
2557
+ if each_keyword.arg == keyword_name:
2558
+ return each_keyword.value
2559
+ if positional_index >= len(call_node.args):
2560
+ return None
2561
+ if any(
2562
+ isinstance(each_argument, ast.Starred)
2563
+ for each_argument in call_node.args[: positional_index + 1]
2564
+ ):
2565
+ return None
2566
+ return call_node.args[positional_index]
2567
+
2568
+
2569
+ def _call_opens_zipfile_write_mode_writer(call_node: ast.Call) -> bool:
2570
+ callee = call_node.func
2571
+ if isinstance(callee, ast.Attribute):
2572
+ callee_name = callee.attr
2573
+ elif isinstance(callee, ast.Name):
2574
+ callee_name = callee.id
2575
+ else:
2576
+ return False
2577
+ if callee_name != ZIPFILE_WRITER_CLASS_NAME:
2578
+ return False
2579
+ mode_argument = _call_argument_by_keyword_or_position(
2580
+ call_node, ZIPFILE_MODE_KEYWORD, ZIPFILE_MODE_POSITIONAL_INDEX
2581
+ )
2582
+ return (
2583
+ isinstance(mode_argument, ast.Constant)
2584
+ and mode_argument.value in ALL_ZIPFILE_WRITE_MODE_VALUES
2585
+ )
2586
+
2587
+
2588
+ def _zipfile_writer_forbids_zip64(call_node: ast.Call) -> bool:
2589
+ allow_zip64_argument = _call_argument_by_keyword_or_position(
2590
+ call_node, ZIPFILE_ALLOW_ZIP64_KEYWORD, ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX
2591
+ )
2592
+ return (
2593
+ isinstance(allow_zip64_argument, ast.Constant)
2594
+ and allow_zip64_argument.value is False
2595
+ )
2596
+
2597
+
2598
+ def _function_documents_unraisable_largezipfile(
2599
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
2600
+ docstring_text: str,
2601
+ ) -> bool:
2602
+ if DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME not in _raises_section_text(docstring_text):
2603
+ return False
2604
+ write_mode_writers = [
2605
+ each_descendant
2606
+ for each_descendant in _walk_skipping_nested_functions(function_node)
2607
+ if isinstance(each_descendant, ast.Call)
2608
+ and _call_opens_zipfile_write_mode_writer(each_descendant)
2609
+ ]
2610
+ if not write_mode_writers:
2611
+ return False
2612
+ return not any(
2613
+ _zipfile_writer_forbids_zip64(each_writer) for each_writer in write_mode_writers
2614
+ )
2615
+
2616
+
2617
+ def _attributes_section_entries(docstring_text: str) -> list[tuple[str, str]]:
2618
+ """Return each ``name: description`` entry under an Attributes section.
2619
+
2620
+ A continuation line indented under an entry joins that entry's description,
2621
+ so a field whose description wraps across lines reads as one string. The
2622
+ scan ends at the next Google-style section header or at the docstring end.
2623
+
2624
+ Args:
2625
+ docstring_text: The full class docstring text to scan.
2626
+
2627
+ Returns:
2628
+ One ``(field_name, description)`` pair per Attributes entry, in order.
2629
+ """
2630
+ all_lines = docstring_text.splitlines()
2631
+ inside_section = False
2632
+ entries: list[tuple[str, str]] = []
2633
+ for each_line in all_lines:
2634
+ stripped_line = each_line.strip()
2635
+ if not inside_section:
2636
+ if stripped_line in ("Attributes:", "Attrs:"):
2637
+ inside_section = True
2638
+ continue
2639
+ if stripped_line.endswith(":") and not each_line.startswith((" ", "\t")):
2640
+ break
2641
+ if stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS:
2642
+ break
2643
+ entry_match = re.match(
2644
+ r"^\s+(?P<field_name>[A-Za-z_][A-Za-z0-9_]*):\s*(?P<description>.*)$",
2645
+ each_line,
2646
+ )
2647
+ if entry_match:
2648
+ entries.append(
2649
+ (entry_match.group("field_name"), entry_match.group("description"))
2650
+ )
2651
+ elif entries and stripped_line:
2652
+ previous_name, previous_description = entries[-1]
2653
+ entries[-1] = (previous_name, f"{previous_description} {stripped_line}")
2654
+ return entries
2655
+
2656
+ def _field_name_is_run_mode_flag(field_name: str) -> bool:
2657
+ lowered_name = field_name.lower()
2658
+ return any(
2659
+ each_token in lowered_name
2660
+ for each_token in ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS
2661
+ )
2662
+
2663
+ def _description_claims_per_record_write_outcome(description: str) -> bool:
2664
+ lowered_description = description.lower()
2665
+ has_write_outcome_phrase = any(
2666
+ each_phrase in lowered_description
2667
+ for each_phrase in ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES
2668
+ )
2669
+ has_run_mode_phrase = any(
2670
+ each_phrase in lowered_description
2671
+ for each_phrase in ALL_DOCSTRING_RUN_MODE_PHRASES
2672
+ )
2673
+ return has_write_outcome_phrase and not has_run_mode_phrase
2674
+
2675
+ def check_docstring_field_runmode_outcome(content: str, file_path: str) -> list[str]:
2676
+ """Flag a run-mode flag field documented as a per-record write outcome.
2677
+
2678
+ The drift this catches: a dataclass or TypedDict field whose name marks a
2679
+ run-mode flag (``is_dry_run``) is documented in the class Attributes block
2680
+ with per-record write-outcome prose (``True when no STP was written``),
2681
+ while the value is set the same way for every record from the run mode
2682
+ (``is_dry_run=not is_execute``). An already-OK record in an execute run then
2683
+ writes no file yet still stores ``False``, so the per-record prose misleads
2684
+ every reader. The check binds only when the description carries a
2685
+ write-outcome phrase and no run-mode phrase, so a field documented by its
2686
+ run-mode meaning is left alone. This is the deterministic single-file slice
2687
+ of Category O6 run-mode-versus-per-record docstring drift.
2688
+
2689
+ Args:
2690
+ content: The source text to inspect.
2691
+ file_path: The path the source will be written to, used for exemptions.
2692
+
2693
+ Returns:
2694
+ One issue per run-mode flag field whose Attributes description claims a
2695
+ per-record write outcome, capped at the module limit.
2696
+ """
2697
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
2698
+ return []
2699
+ try:
2700
+ parsed_tree = ast.parse(content)
2701
+ except SyntaxError:
2702
+ return []
2703
+ issues: list[str] = []
2704
+ for each_node in ast.walk(parsed_tree):
2705
+ if not isinstance(each_node, ast.ClassDef):
2706
+ continue
2707
+ docstring_text = ast.get_docstring(each_node) or ""
2708
+ if not docstring_text:
2709
+ continue
2710
+ for each_field_name, each_description in _attributes_section_entries(docstring_text):
2711
+ if not _field_name_is_run_mode_flag(each_field_name):
2712
+ continue
2713
+ if not _description_claims_per_record_write_outcome(each_description):
2714
+ continue
2715
+ issues.append(
2716
+ f"Line {each_node.lineno}: {each_node.name}.{each_field_name} is a "
2717
+ "run-mode flag but its Attributes description claims a per-record "
2718
+ "write outcome — restate it as the run-mode meaning the assignment "
2719
+ "gives it (Category O6 run-mode-versus-per-record docstring drift)"
2720
+ )
2721
+ if len(issues) >= MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES:
2722
+ return issues[:MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES]
2723
+ return issues[:MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES]
2724
+
2725
+
2726
+ def check_docstring_raises_unraisable_largezipfile(
2727
+ content: str, file_path: str
2728
+ ) -> list[str]:
2729
+ """Flag a Raises clause naming LargeZipFile over a default-ZIP64 writer.
2730
+
2731
+ The drift this catches: a function whose docstring Raises clause lists
2732
+ ``zipfile.LargeZipFile`` while the function opens its ``zipfile.ZipFile``
2733
+ writer in a write mode (``w``/``a``/``x``) with ``allowZip64`` left at its
2734
+ default of True. The stdlib raises ``LargeZipFile`` only when an entry needs
2735
+ ZIP64 AND ``allowZip64`` is False; with ZIP64 permitted the writer
2736
+ transparently uses it and never raises. The Raises entry then documents an
2737
+ exception the body cannot produce, so a caller guarding ``LargeZipFile`` on
2738
+ the strength of the docstring guards an unreachable path. This is the
2739
+ deterministic slice of Category O6 docstring-prose-vs-implementation drift
2740
+ where a writer opened with default ZIP64 disagrees with a LargeZipFile
2741
+ Raises clause.
2742
+
2743
+ The check binds only when the function opens at least one write-mode
2744
+ ``ZipFile`` and every such writer permits ZIP64, so a function that forbids
2745
+ ZIP64 on any writer (``allowZip64=False``, by keyword or position), a
2746
+ read-only open, and a function that opens no writer — where the exception may
2747
+ propagate from a callee — are all left alone.
2748
+
2749
+ Args:
2750
+ content: The source text to inspect.
2751
+ file_path: The path the source will be written to, used for exemptions.
2752
+
2753
+ Returns:
2754
+ One issue per function whose LargeZipFile Raises clause names an
2755
+ unreachable exception, capped at the module limit.
2756
+ """
2757
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
2758
+ return []
2759
+ try:
2760
+ parsed_tree = ast.parse(content)
2761
+ except SyntaxError:
2762
+ return []
2763
+ issues: list[str] = []
2764
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
2765
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2766
+ continue
2767
+ if _function_has_exempt_decorator(each_node):
2768
+ continue
2769
+ docstring_text = _function_docstring_text(each_node)
2770
+ if not docstring_text:
2771
+ continue
2772
+ if not _function_documents_unraisable_largezipfile(each_node, docstring_text):
2773
+ continue
2774
+ issues.append(
2775
+ f"Line {each_node.lineno}: {each_node.name}() docstring Raises lists "
2776
+ "zipfile.LargeZipFile, but the function opens its ZipFile writer with ZIP64 "
2777
+ "permitted (allowZip64 defaults to True) — LargeZipFile raises only when "
2778
+ "allowZip64 is False, so drop the entry or pass allowZip64=False "
2779
+ "(Category O6 docstring-vs-implementation drift)"
2780
+ )
2781
+ if len(issues) >= MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES:
2782
+ break
2783
+ return issues[:MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES]