claude-dev-env 1.78.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 (35) 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 +3 -2
  5. package/hooks/blocking/code_rules_docstrings.py +609 -16
  6. package/hooks/blocking/code_rules_enforcer.py +37 -0
  7. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  8. package/hooks/blocking/code_rules_paired_test.py +240 -22
  9. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  10. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  11. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  12. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  16. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
  18. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  19. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  20. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  21. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  23. package/hooks/hooks_constants/CLAUDE.md +1 -0
  24. package/hooks/hooks_constants/blocking_check_limits.py +78 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  26. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  27. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  28. package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
  29. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  30. package/package.json +1 -1
  31. package/rules/CLAUDE.md +1 -0
  32. package/rules/docstring-prose-matches-implementation.md +56 -54
  33. package/rules/env-var-table-code-drift.md +24 -0
  34. package/rules/package-inventory-stale-entry.md +4 -4
  35. package/rules/paired-test-coverage.md +12 -5
@@ -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,46 +32,53 @@ 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,
37
49
  ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES,
50
+ ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES,
51
+ ALL_ZIPFILE_WRITE_MODE_VALUES,
38
52
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
53
+ DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME,
39
54
  DOCSTRING_REFERENCE_MARKER_WINDOW,
40
55
  DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
41
56
  DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
42
57
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
58
+ LENGTH_CONFIG_SUBDIRECTORY_NAME,
59
+ LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT,
43
60
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
44
61
  MAX_COMPANION_MODULE_RESOLUTION_DEPTH,
45
- PYTHON_MODULE_FILE_SUFFIX,
46
- WORD_BOUNDARY_REGEX,
47
62
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
63
+ MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
48
64
  MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
49
65
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
66
+ MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES,
50
67
  MAX_DOCSTRING_FORMAT_ISSUES,
51
68
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
52
69
  MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES,
53
70
  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,
71
+ MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES,
61
72
  MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES,
62
- MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
63
73
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
74
+ MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
75
+ MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
64
76
  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,
70
77
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
71
78
  MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
79
+ MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES,
72
80
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
81
+ MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES,
73
82
  MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
74
83
  MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
75
84
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
@@ -78,7 +87,15 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
78
87
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
79
88
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
80
89
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
90
+ MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
91
+ PYTHON_MODULE_FILE_SUFFIX,
81
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,
82
99
  )
83
100
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
84
101
  ALL_CAPS_WITH_UNDERSCORE_PATTERN,
@@ -369,6 +386,71 @@ def check_docstring_args_match_signature(content: str, file_path: str) -> list[s
369
386
  return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
370
387
 
371
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
+
372
454
  def _callee_expression_name(expression: ast.expr) -> str:
373
455
  if isinstance(expression, ast.Name):
374
456
  return expression.id
@@ -791,6 +873,92 @@ def check_docstring_unguarded_malformed_payload_claim(
791
873
  return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
792
874
 
793
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
+
794
962
  def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
795
963
  collapsed_docstring = " ".join(module_docstring.lower().split())
796
964
  for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
@@ -928,6 +1096,105 @@ def check_module_docstring_names_public_checks(content: str, file_path: str) ->
928
1096
  return issues[:MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES]
929
1097
 
930
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
+
931
1198
  def _module_string_tuple_members(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
932
1199
  members_by_constant: dict[str, frozenset[str]] = {}
933
1200
  for each_statement in parsed_tree.body:
@@ -1336,6 +1603,223 @@ def check_docstring_returns_plural_cardinality(content: str, file_path: str) ->
1336
1603
  return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
1337
1604
 
1338
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
+
1339
1823
  def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
1340
1824
  members_by_family: dict[str, set[str]] = {}
1341
1825
  for each_node in ast.walk(parsed_tree):
@@ -2007,7 +2491,7 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
2007
2491
  A readable docstring breaks its narrative into short sentences a general
2008
2492
  developer follows on the first read. The one mechanical mark of a wall is a
2009
2493
  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,
2494
+ em-dash, a double-hyphen, or a semicolon. This check inspects the narrative prose of module,
2011
2495
  class, and public-function docstrings — the text before the first structured
2012
2496
  section header (``Args:``, ``Arguments:``, ``Returns:``, ``Yields:``,
2013
2497
  ``Raises:``, ``Note:``, ``Notes:``, ``Example:``, or ``Examples:``) — and
@@ -2130,6 +2614,115 @@ def _function_documents_unraisable_largezipfile(
2130
2614
  )
2131
2615
 
2132
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
+
2133
2726
  def check_docstring_raises_unraisable_largezipfile(
2134
2727
  content: str, file_path: str
2135
2728
  ) -> list[str]: