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.
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +3 -2
- package/hooks/blocking/CLAUDE.md +5 -2
- 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 +951 -6
- package/hooks/blocking/code_rules_enforcer.py +64 -0
- package/hooks/blocking/code_rules_naming_collection.py +76 -1
- package/hooks/blocking/code_rules_paired_test.py +517 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/code_rules_test_assertions.py +159 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -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_field_runmode_outcome.py +129 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +339 -0
- package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -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/blocking/test_env_var_table_code_drift_blocker.py +94 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +102 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
- package/hooks/hooks_constants/paired_test_coverage_constants.py +35 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +56 -53
- package/rules/env-var-table-code-drift.md +24 -0
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +4 -4
- 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]
|