claude-dev-env 2.28.0 → 2.28.1
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/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +16 -29
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/docs/CODE_RULES.md +3 -3
- package/docs/agent-spawn-protocol.md +1 -1
- package/docs/references/prose-style-enforcement.md +5 -9
- package/hooks/blocking/AGENTS.md +3 -13
- package/hooks/blocking/code_rules_docstrings.py +10 -2138
- package/hooks/blocking/code_rules_enforcer.py +0 -121
- package/hooks/blocking/code_rules_imports_logging.py +1 -236
- package/hooks/blocking/code_rules_shared.py +23 -0
- package/hooks/blocking/code_rules_test_layout.py +8 -8
- package/hooks/blocking/config/prose_style_enforcement_constants.py +4 -2
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +5 -1
- package/hooks/blocking/precommit_code_rules_gate.py +8 -43
- package/hooks/blocking/state_description_blocker.py +1 -7
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +0 -8
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +11 -112
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +1 -18
- package/hooks/blocking/test_code_rules_shared.py +12 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +32 -179
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -1
- package/hooks/blocking/test_state_description_blocker.py +6 -4
- package/hooks/blocking/test_stop_dispatcher.py +5 -29
- package/hooks/hooks_constants/AGENTS.md +1 -6
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/messages.py +0 -2
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +3 -17
- package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/test_messages.py +5 -3
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -2
- package/package.json +1 -1
- package/rules/AGENTS.md +3 -6
- package/rules/claims-as-quotes.md +0 -10
- package/rules/code-standards.md +6 -6
- package/rules/explore-thoroughly.md +0 -1
- package/rules/failure-blast-radius.md +0 -8
- package/rules/falsify-before-green.md +0 -8
- package/rules/file-global-constants.md +2 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +1 -9
- package/rules/hedging-claims.md +2 -6
- package/rules/long-horizon-autonomy.md +1 -1
- package/rules/measurement-denominators.md +0 -9
- package/rules/research-mode.md +0 -6
- package/rules/verify-before-asking.md +0 -5
- package/rules/verify-runtime-state.md +0 -5
- package/scripts/codex_compat_materializer.py +0 -12
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/tests/test_engine.py +0 -1
- package/scripts/tests/test_rules.py +0 -1
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/system-prompts/software-engineer.xml +3 -3
- package/hooks/blocking/code_rules_dead_argparse_argument.py +0 -554
- package/hooks/blocking/code_rules_dead_config_field.py +0 -568
- package/hooks/blocking/code_rules_dead_dataclass_field.py +0 -348
- package/hooks/blocking/code_rules_dead_module_constant.py +0 -757
- package/hooks/blocking/code_rules_dead_split_branch.py +0 -225
- package/hooks/blocking/code_rules_mock_completeness.py +0 -295
- package/hooks/blocking/code_rules_scope_binding.py +0 -151
- package/hooks/blocking/code_rules_unused_imports.py +0 -197
- package/hooks/blocking/hedging_language_blocker.py +0 -221
- package/hooks/blocking/intent_only_ending_blocker.py +0 -148
- package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +0 -534
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +0 -846
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +0 -507
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +0 -679
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +0 -133
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +0 -103
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +0 -105
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +0 -425
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +0 -176
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +0 -385
- package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +0 -398
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +0 -129
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +0 -93
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +0 -198
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +0 -262
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +0 -93
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +0 -115
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +0 -226
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +0 -207
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +0 -262
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +0 -164
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +0 -188
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +0 -157
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +0 -303
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +0 -111
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +0 -656
- package/hooks/blocking/test_hedging_language_blocker.py +0 -261
- package/hooks/blocking/test_intent_only_ending_blocker.py +0 -209
- package/hooks/hooks_constants/dead_argparse_argument_constants.py +0 -28
- package/hooks/hooks_constants/dead_config_field_constants.py +0 -39
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +0 -25
- package/hooks/hooks_constants/dead_module_constant_constants.py +0 -30
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +0 -42
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +0 -44
- package/rules/conservative-action.md +0 -17
- package/rules/context7.md +0 -8
- package/rules/few-words.md +0 -3
- package/rules/parallel-tools.md +0 -23
|
@@ -23,107 +23,41 @@ from code_rules_shared import ( # noqa: E402
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
26
|
-
ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES,
|
|
27
26
|
ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS,
|
|
28
27
|
ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES,
|
|
29
|
-
ALL_DELEGATION_ENUMERATION_STOP_TOKENS,
|
|
30
|
-
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
|
|
31
28
|
ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
|
|
32
29
|
ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
|
|
33
|
-
ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES,
|
|
34
30
|
ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
|
|
35
|
-
ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
|
|
36
|
-
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
|
|
37
|
-
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
|
|
38
|
-
ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES,
|
|
39
31
|
ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
|
|
40
|
-
ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES,
|
|
41
|
-
ALL_DOCSTRING_RUN_MODE_PHRASES,
|
|
42
|
-
ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS,
|
|
43
32
|
ALL_DOCSTRING_RUNON_JOINER_MARKERS,
|
|
44
|
-
ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES,
|
|
45
|
-
ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES,
|
|
46
|
-
ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES,
|
|
47
33
|
ALL_GENERIC_CHECK_NAME_TOKENS,
|
|
48
|
-
ALL_LENGTH_CONSTANT_NAME_SUFFIXES,
|
|
49
|
-
ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES,
|
|
50
34
|
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
|
|
51
|
-
ALL_PATH_METADATA_ACCESS_METHOD_NAMES,
|
|
52
|
-
ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES,
|
|
53
35
|
ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES,
|
|
54
|
-
ALL_ZIPFILE_WRITE_MODE_VALUES,
|
|
55
|
-
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
|
|
56
|
-
DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME,
|
|
57
36
|
DOCSTRING_NARRATIVE_LINE_JOIN_SEPARATOR,
|
|
58
37
|
DOCSTRING_NARRATIVE_PROSE_LINE_LIMIT,
|
|
59
38
|
DOCSTRING_REFERENCE_MARKER_WINDOW,
|
|
60
39
|
DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN,
|
|
61
40
|
DOCSTRING_RUNON_SENTENCE_WORD_LIMIT,
|
|
62
41
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
|
|
63
|
-
LENGTH_CONFIG_SUBDIRECTORY_NAME,
|
|
64
|
-
LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT,
|
|
65
42
|
MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
|
|
66
|
-
MAX_COMPANION_MODULE_RESOLUTION_DEPTH,
|
|
67
43
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
68
|
-
MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES,
|
|
69
|
-
MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES,
|
|
70
|
-
MAX_DOCSTRING_DELEGATION_ENUMERATION_ISSUES,
|
|
71
|
-
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
|
|
72
|
-
MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES,
|
|
73
44
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
74
|
-
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
|
|
75
|
-
MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES,
|
|
76
|
-
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
|
|
77
|
-
MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES,
|
|
78
45
|
MAX_DOCSTRING_PROSE_WALL_ISSUES,
|
|
79
|
-
MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES,
|
|
80
|
-
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
|
|
81
46
|
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
|
|
82
|
-
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
83
|
-
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
84
|
-
MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES,
|
|
85
47
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
86
|
-
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
|
|
87
|
-
MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES,
|
|
88
48
|
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
|
|
89
49
|
MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES,
|
|
90
|
-
MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK,
|
|
91
|
-
MINIMUM_DELEGATION_ENUMERATION_ITEMS,
|
|
92
|
-
MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK,
|
|
93
|
-
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
|
|
94
|
-
MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
|
|
95
50
|
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
96
51
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
97
|
-
MINIMUM_RANGE_ARGS_FOR_SPAN,
|
|
98
52
|
MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN,
|
|
99
|
-
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
|
|
100
|
-
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
|
|
101
53
|
MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
|
|
102
|
-
NEIGHBOR_SCAN_FILE_LIMIT,
|
|
103
|
-
POINTER_TO_DELEGATE_PATTERN,
|
|
104
|
-
PYTHON_MODULE_FILE_SUFFIX,
|
|
105
|
-
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
|
|
106
|
-
TOKEN_WORD_PATTERN,
|
|
107
|
-
TYPE_CHECKING_IDENTIFIER_MARKER,
|
|
108
|
-
WORD_BOUNDARY_REGEX,
|
|
109
|
-
ZIPFILE_ALLOW_ZIP64_KEYWORD,
|
|
110
|
-
ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX,
|
|
111
|
-
ZIPFILE_MODE_KEYWORD,
|
|
112
|
-
ZIPFILE_MODE_POSITIONAL_INDEX,
|
|
113
|
-
ZIPFILE_WRITER_CLASS_NAME,
|
|
114
54
|
)
|
|
115
55
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
116
56
|
ALL_CAPS_WITH_UNDERSCORE_PATTERN,
|
|
117
|
-
ALL_CARDINAL_NUMBER_WORD_VALUES,
|
|
118
57
|
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
119
58
|
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
120
59
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
121
60
|
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
122
|
-
DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN,
|
|
123
|
-
DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN,
|
|
124
|
-
DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
|
|
125
|
-
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
|
|
126
|
-
INLINE_CODE_TOKEN_PATTERN,
|
|
127
61
|
)
|
|
128
62
|
|
|
129
63
|
|
|
@@ -466,158 +400,6 @@ def check_docstring_documents_unreferenced_parameter(
|
|
|
466
400
|
return issues[:MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES]
|
|
467
401
|
|
|
468
402
|
|
|
469
|
-
def _callee_expression_name(expression: ast.expr) -> str:
|
|
470
|
-
if isinstance(expression, ast.Name):
|
|
471
|
-
return expression.id
|
|
472
|
-
if isinstance(expression, ast.Attribute):
|
|
473
|
-
receiver_name = _callee_expression_name(expression.value)
|
|
474
|
-
if not receiver_name:
|
|
475
|
-
return ast.unparse(expression)
|
|
476
|
-
return f"{receiver_name}.{expression.attr}"
|
|
477
|
-
return ""
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
def _call_callee_name(call_node: ast.Call) -> str:
|
|
481
|
-
return _callee_expression_name(call_node.func)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def _branch_routes_directly_to_call(branch_node: ast.If) -> str:
|
|
485
|
-
"""Return the callee name an early-return guard routes to, or empty string.
|
|
486
|
-
|
|
487
|
-
A guard counts when its block contains exactly one call expression and then
|
|
488
|
-
returns. A second call expression disqualifies the block; non-call
|
|
489
|
-
statements such as an assignment or a loop are skipped and do not
|
|
490
|
-
disqualify it. The await wrapper around an async call is unwrapped first.
|
|
491
|
-
"""
|
|
492
|
-
routed_callee = ""
|
|
493
|
-
saw_return = False
|
|
494
|
-
for each_statement in branch_node.body:
|
|
495
|
-
candidate_expression: ast.expr | None = None
|
|
496
|
-
if isinstance(each_statement, ast.Expr):
|
|
497
|
-
candidate_expression = each_statement.value
|
|
498
|
-
elif isinstance(each_statement, ast.Return):
|
|
499
|
-
saw_return = True
|
|
500
|
-
continue
|
|
501
|
-
if candidate_expression is None:
|
|
502
|
-
continue
|
|
503
|
-
if isinstance(candidate_expression, ast.Await):
|
|
504
|
-
candidate_expression = candidate_expression.value
|
|
505
|
-
if not isinstance(candidate_expression, ast.Call):
|
|
506
|
-
return ""
|
|
507
|
-
if routed_callee:
|
|
508
|
-
return ""
|
|
509
|
-
routed_callee = _call_callee_name(candidate_expression)
|
|
510
|
-
if not saw_return:
|
|
511
|
-
return ""
|
|
512
|
-
return routed_callee
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def _shared_fallback_route_count(
|
|
516
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
517
|
-
) -> tuple[str, int]:
|
|
518
|
-
route_count_by_callee: dict[str, int] = {}
|
|
519
|
-
for each_statement in function_node.body:
|
|
520
|
-
if not isinstance(each_statement, ast.If):
|
|
521
|
-
continue
|
|
522
|
-
routed_callee = _branch_routes_directly_to_call(each_statement)
|
|
523
|
-
if not routed_callee:
|
|
524
|
-
continue
|
|
525
|
-
route_count_by_callee[routed_callee] = (
|
|
526
|
-
route_count_by_callee.get(routed_callee, 0) + 1
|
|
527
|
-
)
|
|
528
|
-
if not route_count_by_callee:
|
|
529
|
-
return "", 0
|
|
530
|
-
busiest_callee = max(route_count_by_callee, key=lambda name: route_count_by_callee[name])
|
|
531
|
-
return busiest_callee, route_count_by_callee[busiest_callee]
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def _summary_contains_phrase_at_word_boundary(summary_text: str, phrase: str) -> bool:
|
|
535
|
-
search_start = 0
|
|
536
|
-
while True:
|
|
537
|
-
match_index = summary_text.find(phrase, search_start)
|
|
538
|
-
if match_index == -1:
|
|
539
|
-
return False
|
|
540
|
-
preceding_is_boundary = (
|
|
541
|
-
match_index == 0 or not summary_text[match_index - 1].isalnum()
|
|
542
|
-
)
|
|
543
|
-
following_index = match_index + len(phrase)
|
|
544
|
-
following_is_boundary = (
|
|
545
|
-
following_index >= len(summary_text)
|
|
546
|
-
or not summary_text[following_index].isalnum()
|
|
547
|
-
)
|
|
548
|
-
if preceding_is_boundary and following_is_boundary:
|
|
549
|
-
return True
|
|
550
|
-
search_start = match_index + 1
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _summary_joins_multiple_conditions(summary_text: str) -> bool:
|
|
554
|
-
return any(
|
|
555
|
-
joining_phrase in summary_text
|
|
556
|
-
for joining_phrase in ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES
|
|
557
|
-
)
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
def _docstring_summary_scopes_a_single_condition(docstring_text: str) -> bool:
|
|
561
|
-
summary_text = docstring_text.split("\n\n", 1)[0].lower()
|
|
562
|
-
has_scope_phrase = any(
|
|
563
|
-
_summary_contains_phrase_at_word_boundary(summary_text, each_phrase)
|
|
564
|
-
for each_phrase in ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES
|
|
565
|
-
)
|
|
566
|
-
if not has_scope_phrase:
|
|
567
|
-
return False
|
|
568
|
-
return not _summary_joins_multiple_conditions(summary_text)
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def check_docstring_fallback_branch_coverage(content: str, file_path: str) -> list[str]:
|
|
572
|
-
"""Flag a fallback docstring that scopes a branch the body reaches twice.
|
|
573
|
-
|
|
574
|
-
The drift this catches: a function whose summary describes a fallback
|
|
575
|
-
action under a single condition (``only when``, ``falls back to ... when``)
|
|
576
|
-
while the body routes to that same fallback call from two or more distinct
|
|
577
|
-
early-return guards. The second guard fires under a condition the prose
|
|
578
|
-
never names, so the enumeration the reader trusts is incomplete. This is
|
|
579
|
-
the deterministic slice of Category O6 (docstring prose vs implementation
|
|
580
|
-
drift): a structural branch-count-versus-prose-condition mismatch.
|
|
581
|
-
|
|
582
|
-
Args:
|
|
583
|
-
content: The source text to inspect.
|
|
584
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
585
|
-
|
|
586
|
-
Returns:
|
|
587
|
-
One issue per function whose fallback prose omits a second route to the
|
|
588
|
-
same call, capped at the module limit.
|
|
589
|
-
"""
|
|
590
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
591
|
-
return []
|
|
592
|
-
try:
|
|
593
|
-
parsed_tree = ast.parse(content)
|
|
594
|
-
except SyntaxError:
|
|
595
|
-
return []
|
|
596
|
-
issues: list[str] = []
|
|
597
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
598
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
599
|
-
continue
|
|
600
|
-
if _function_has_exempt_decorator(each_node):
|
|
601
|
-
continue
|
|
602
|
-
docstring_text = _function_docstring_text(each_node)
|
|
603
|
-
if not docstring_text:
|
|
604
|
-
continue
|
|
605
|
-
if not _docstring_summary_scopes_a_single_condition(docstring_text):
|
|
606
|
-
continue
|
|
607
|
-
fallback_callee, route_count = _shared_fallback_route_count(each_node)
|
|
608
|
-
if route_count < DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT:
|
|
609
|
-
continue
|
|
610
|
-
issues.append(
|
|
611
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring scopes a fallback to "
|
|
612
|
-
f"one condition, but the body routes to {fallback_callee}() from {route_count} "
|
|
613
|
-
"distinct branches — enumerate every condition that reaches the fallback "
|
|
614
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
615
|
-
)
|
|
616
|
-
if len(issues) >= MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES:
|
|
617
|
-
break
|
|
618
|
-
return issues[:MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES]
|
|
619
|
-
|
|
620
|
-
|
|
621
403
|
def _class_docstring_summary_is_single_line(docstring_text: str) -> bool:
|
|
622
404
|
stripped_text = docstring_text.strip()
|
|
623
405
|
if not stripped_text:
|
|
@@ -713,405 +495,6 @@ def check_class_docstring_names_public_methods(
|
|
|
713
495
|
return issues[:MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES]
|
|
714
496
|
|
|
715
497
|
|
|
716
|
-
def _docstring_claims_no_consumer(docstring_text: str) -> str:
|
|
717
|
-
lowered_docstring = docstring_text.lower()
|
|
718
|
-
for each_phrase in ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES:
|
|
719
|
-
if each_phrase in lowered_docstring:
|
|
720
|
-
return each_phrase
|
|
721
|
-
return ""
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]:
|
|
725
|
-
"""Flag a docstring that asserts no consumer reads its produced artifact yet.
|
|
726
|
-
|
|
727
|
-
A producer docstring claiming "no consumer reads it yet" (or
|
|
728
|
-
"producer-only artifact") is a transitional statement that drifts the moment
|
|
729
|
-
a consumer lands. Once a submission run, gate, or any reader loads the
|
|
730
|
-
artifact, the claim contradicts both the live behavior and any companion
|
|
731
|
-
SKILL.md that documents the consumer — the Category O8 docstring /
|
|
732
|
-
companion-doc producer-consumer drift. The claim is also a no-historical /
|
|
733
|
-
no-transitional-language violation in its own right: a docstring describes
|
|
734
|
-
the contract that exists, not a not-yet-wired future. Rephrase to state what
|
|
735
|
-
reads the artifact, or drop the no-consumer sentence entirely.
|
|
736
|
-
|
|
737
|
-
Args:
|
|
738
|
-
content: The source text to inspect.
|
|
739
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
740
|
-
|
|
741
|
-
Returns:
|
|
742
|
-
One issue per function whose docstring claims no consumer reads its
|
|
743
|
-
output, capped at the module limit.
|
|
744
|
-
"""
|
|
745
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
746
|
-
return []
|
|
747
|
-
try:
|
|
748
|
-
parsed_tree = ast.parse(content)
|
|
749
|
-
except SyntaxError:
|
|
750
|
-
return []
|
|
751
|
-
issues: list[str] = []
|
|
752
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
753
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
754
|
-
continue
|
|
755
|
-
if _function_has_exempt_decorator(each_node):
|
|
756
|
-
continue
|
|
757
|
-
docstring_text = _function_docstring_text(each_node)
|
|
758
|
-
if not docstring_text:
|
|
759
|
-
continue
|
|
760
|
-
matched_phrase = _docstring_claims_no_consumer(docstring_text)
|
|
761
|
-
if not matched_phrase:
|
|
762
|
-
continue
|
|
763
|
-
issues.append(
|
|
764
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring claims "
|
|
765
|
-
f"'{matched_phrase}' — a no-consumer-yet claim drifts the moment a reader "
|
|
766
|
-
"lands and contradicts any companion SKILL.md; state what reads the artifact "
|
|
767
|
-
"or drop the sentence (Category O8 docstring / companion-doc drift)"
|
|
768
|
-
)
|
|
769
|
-
if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
|
|
770
|
-
break
|
|
771
|
-
return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
def _docstring_claims_malformed_payload_is_guarded(docstring_text: str) -> str:
|
|
775
|
-
collapsed_docstring = " ".join(docstring_text.lower().split())
|
|
776
|
-
for each_phrase in ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES:
|
|
777
|
-
if each_phrase in collapsed_docstring:
|
|
778
|
-
return each_phrase
|
|
779
|
-
return ""
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
def _try_handler_returns_none(try_node: ast.Try) -> bool:
|
|
783
|
-
for each_handler in try_node.handlers:
|
|
784
|
-
for each_statement in each_handler.body:
|
|
785
|
-
if isinstance(each_statement, ast.Return) and isinstance(
|
|
786
|
-
each_statement.value, ast.Constant
|
|
787
|
-
):
|
|
788
|
-
if each_statement.value.value is None:
|
|
789
|
-
return True
|
|
790
|
-
return False
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
def _names_bound_in_try_body(try_node: ast.Try) -> set[str]:
|
|
794
|
-
all_bound_names: set[str] = set()
|
|
795
|
-
for each_statement in try_node.body:
|
|
796
|
-
for each_descendant in ast.walk(each_statement):
|
|
797
|
-
if isinstance(each_descendant, ast.Name) and isinstance(
|
|
798
|
-
each_descendant.ctx, ast.Store
|
|
799
|
-
):
|
|
800
|
-
all_bound_names.add(each_descendant.id)
|
|
801
|
-
return all_bound_names
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def _statement_subscripts_one_of(
|
|
805
|
-
statement: ast.stmt, all_payload_names: set[str]
|
|
806
|
-
) -> bool:
|
|
807
|
-
for each_descendant in ast.walk(statement):
|
|
808
|
-
if (
|
|
809
|
-
isinstance(each_descendant, ast.Subscript)
|
|
810
|
-
and isinstance(each_descendant.value, ast.Name)
|
|
811
|
-
and each_descendant.value.id in all_payload_names
|
|
812
|
-
):
|
|
813
|
-
return True
|
|
814
|
-
return False
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
def _function_has_unguarded_payload_dereference(
|
|
818
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
819
|
-
) -> bool:
|
|
820
|
-
all_payload_names: set[str] = set()
|
|
821
|
-
saw_returning_guard = False
|
|
822
|
-
for each_statement in function_node.body:
|
|
823
|
-
if isinstance(each_statement, ast.Try) and _try_handler_returns_none(
|
|
824
|
-
each_statement
|
|
825
|
-
):
|
|
826
|
-
all_payload_names |= _names_bound_in_try_body(each_statement)
|
|
827
|
-
saw_returning_guard = True
|
|
828
|
-
continue
|
|
829
|
-
if not saw_returning_guard:
|
|
830
|
-
continue
|
|
831
|
-
if _statement_subscripts_one_of(each_statement, all_payload_names):
|
|
832
|
-
return True
|
|
833
|
-
return False
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
def check_docstring_unguarded_malformed_payload_claim(
|
|
837
|
-
content: str, file_path: str
|
|
838
|
-
) -> list[str]:
|
|
839
|
-
"""Flag a docstring that promises malformed-payload safety the guard misses.
|
|
840
|
-
|
|
841
|
-
The drift this catches: a function whose docstring states that a malformed
|
|
842
|
-
payload "resolves to None" while a subscript dereference of that payload
|
|
843
|
-
(``payload["key"]``, ``float(payload["key"])``) sits OUTSIDE the try/except
|
|
844
|
-
whose handler returns None. A present-but-malformed payload then raises
|
|
845
|
-
KeyError or TypeError from that unguarded access and propagates rather than
|
|
846
|
-
resolving to None, so the docstring overstates the protection. This is the
|
|
847
|
-
deterministic slice of Category O6 (docstring prose vs implementation drift)
|
|
848
|
-
for an exception-guard claim: move the dereference inside the guarded block,
|
|
849
|
-
or narrow the docstring to the failures the guard actually catches.
|
|
850
|
-
|
|
851
|
-
Args:
|
|
852
|
-
content: The source text to inspect.
|
|
853
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
854
|
-
|
|
855
|
-
Returns:
|
|
856
|
-
One issue per function whose malformed-payload claim outruns its guard,
|
|
857
|
-
capped at the module limit.
|
|
858
|
-
"""
|
|
859
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
860
|
-
return []
|
|
861
|
-
try:
|
|
862
|
-
parsed_tree = ast.parse(content)
|
|
863
|
-
except SyntaxError:
|
|
864
|
-
return []
|
|
865
|
-
issues: list[str] = []
|
|
866
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
867
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
868
|
-
continue
|
|
869
|
-
if _function_has_exempt_decorator(each_node):
|
|
870
|
-
continue
|
|
871
|
-
docstring_text = _function_docstring_text(each_node)
|
|
872
|
-
if not docstring_text:
|
|
873
|
-
continue
|
|
874
|
-
matched_phrase = _docstring_claims_malformed_payload_is_guarded(docstring_text)
|
|
875
|
-
if not matched_phrase:
|
|
876
|
-
continue
|
|
877
|
-
if not _function_has_unguarded_payload_dereference(each_node):
|
|
878
|
-
continue
|
|
879
|
-
issues.append(
|
|
880
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring claims "
|
|
881
|
-
f"'{matched_phrase}' but a payload subscript sits outside the try/except that "
|
|
882
|
-
"returns None — a malformed-but-present payload raises rather than resolving to "
|
|
883
|
-
"None; move the dereference inside the guard or narrow the docstring "
|
|
884
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
885
|
-
)
|
|
886
|
-
if len(issues) >= MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES:
|
|
887
|
-
break
|
|
888
|
-
return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
def _docstring_claims_no_network_access(docstring_text: str) -> str:
|
|
892
|
-
collapsed_docstring = " ".join(docstring_text.lower().split())
|
|
893
|
-
for each_phrase in ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES:
|
|
894
|
-
if each_phrase in collapsed_docstring:
|
|
895
|
-
return each_phrase
|
|
896
|
-
return ""
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
def _function_performs_path_metadata_access(
|
|
900
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
901
|
-
) -> str:
|
|
902
|
-
for each_descendant in ast.walk(function_node):
|
|
903
|
-
if not isinstance(each_descendant, ast.Call):
|
|
904
|
-
continue
|
|
905
|
-
callee = each_descendant.func
|
|
906
|
-
if (
|
|
907
|
-
isinstance(callee, ast.Attribute)
|
|
908
|
-
and callee.attr in ALL_PATH_METADATA_ACCESS_METHOD_NAMES
|
|
909
|
-
):
|
|
910
|
-
return callee.attr
|
|
911
|
-
return ""
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
def check_docstring_no_network_claim_with_metadata_access(
|
|
915
|
-
content: str, file_path: str
|
|
916
|
-
) -> list[str]:
|
|
917
|
-
"""Flag a docstring promising no network touch while the body stats a path.
|
|
918
|
-
|
|
919
|
-
Picture a cache helper whose docstring says it returns the warm cache
|
|
920
|
-
"without touching the network." A reader trusts that a cache hit costs
|
|
921
|
-
nothing on the share. But the body calls ``share_path.is_file()`` and
|
|
922
|
-
``share_path.stat().st_size`` before it can return the cache — and on a
|
|
923
|
-
network share each of those metadata calls is a round-trip over the wire.
|
|
924
|
-
The promise drifts: every cache hit still pays two network stats the
|
|
925
|
-
docstring swore it avoided.
|
|
926
|
-
|
|
927
|
-
This is the deterministic slice of Category O6 (docstring prose versus
|
|
928
|
-
implementation drift) for a no-network claim: a function docstring that
|
|
929
|
-
states the path returns ``without touching the network`` (or a sibling
|
|
930
|
-
no-network phrase) while the body calls ``is_file``, ``is_dir``,
|
|
931
|
-
``exists``, ``stat``, or ``lstat``. Either reword the claim to state that
|
|
932
|
-
the path is stat-checked on every call, or short-circuit to the cached
|
|
933
|
-
path before the share is touched.
|
|
934
|
-
|
|
935
|
-
Args:
|
|
936
|
-
content: The source text to inspect.
|
|
937
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
938
|
-
|
|
939
|
-
Returns:
|
|
940
|
-
One issue per function whose no-network claim coexists with a
|
|
941
|
-
path-metadata call, capped at the module limit.
|
|
942
|
-
"""
|
|
943
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
944
|
-
return []
|
|
945
|
-
try:
|
|
946
|
-
parsed_tree = ast.parse(content)
|
|
947
|
-
except SyntaxError:
|
|
948
|
-
return []
|
|
949
|
-
issues: list[str] = []
|
|
950
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
951
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
952
|
-
continue
|
|
953
|
-
if _function_has_exempt_decorator(each_node):
|
|
954
|
-
continue
|
|
955
|
-
docstring_text = _function_docstring_text(each_node)
|
|
956
|
-
if not docstring_text:
|
|
957
|
-
continue
|
|
958
|
-
matched_phrase = _docstring_claims_no_network_access(docstring_text)
|
|
959
|
-
if not matched_phrase:
|
|
960
|
-
continue
|
|
961
|
-
accessed_method = _function_performs_path_metadata_access(each_node)
|
|
962
|
-
if not accessed_method:
|
|
963
|
-
continue
|
|
964
|
-
issues.append(
|
|
965
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring claims "
|
|
966
|
-
f"'{matched_phrase}' but the body calls .{accessed_method}() on a path — "
|
|
967
|
-
"a metadata stat is itself a network touch on a share, so the no-network "
|
|
968
|
-
"claim drifts; reword the docstring to state the path is stat-checked on "
|
|
969
|
-
"every call, or short-circuit before touching the share "
|
|
970
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
971
|
-
)
|
|
972
|
-
if len(issues) >= MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES:
|
|
973
|
-
break
|
|
974
|
-
return issues[:MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES]
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
def _module_code_references_type_checking(parsed_tree: ast.Module) -> bool:
|
|
978
|
-
"""Return True when a code identifier in the module handles TYPE_CHECKING.
|
|
979
|
-
|
|
980
|
-
A module that names TYPE_CHECKING in code genuinely gates on it. That covers
|
|
981
|
-
a Name reference (any context — load, store, or delete), an import alias, an
|
|
982
|
-
attribute access, or a function or class definition whose name carries the
|
|
983
|
-
type_checking marker. Docstring text is an ast.Constant and never counts, so
|
|
984
|
-
the check can still flag a gate the prose names while the body performs none.
|
|
985
|
-
"""
|
|
986
|
-
for each_node in ast.walk(parsed_tree):
|
|
987
|
-
if isinstance(each_node, ast.Name) and (
|
|
988
|
-
TYPE_CHECKING_IDENTIFIER_MARKER in each_node.id.lower()
|
|
989
|
-
):
|
|
990
|
-
return True
|
|
991
|
-
if isinstance(each_node, ast.Attribute) and (
|
|
992
|
-
TYPE_CHECKING_IDENTIFIER_MARKER in each_node.attr.lower()
|
|
993
|
-
):
|
|
994
|
-
return True
|
|
995
|
-
if isinstance(each_node, ast.alias) and any(
|
|
996
|
-
each_alias_name and TYPE_CHECKING_IDENTIFIER_MARKER in each_alias_name.lower()
|
|
997
|
-
for each_alias_name in (each_node.name, each_node.asname)
|
|
998
|
-
):
|
|
999
|
-
return True
|
|
1000
|
-
if isinstance(
|
|
1001
|
-
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1002
|
-
) and TYPE_CHECKING_IDENTIFIER_MARKER in each_node.name.lower():
|
|
1003
|
-
return True
|
|
1004
|
-
return False
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
def _docstring_names_absent_type_checking_gate(docstring_text: str) -> str:
|
|
1008
|
-
collapsed_docstring = " ".join(docstring_text.replace("`", " ").lower().split())
|
|
1009
|
-
for each_phrase in ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES:
|
|
1010
|
-
if each_phrase in collapsed_docstring:
|
|
1011
|
-
return each_phrase
|
|
1012
|
-
return ""
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
def check_docstring_names_absent_type_checking_gate(
|
|
1016
|
-
content: str, file_path: str
|
|
1017
|
-
) -> list[str]:
|
|
1018
|
-
"""Flag a docstring naming a TYPE_CHECKING gate the module's code never runs.
|
|
1019
|
-
|
|
1020
|
-
Picture a hook whose docstring advertises a `TYPE_CHECKING` gate-detection
|
|
1021
|
-
step, or a module summary that names `type-checking-gate` helpers, while no
|
|
1022
|
-
identifier in the body handles TYPE_CHECKING. A reader trusts the prose and
|
|
1023
|
-
looks for the gate, but the code performs none, so the claim points at
|
|
1024
|
-
machinery the module does not hold.
|
|
1025
|
-
|
|
1026
|
-
This is the deterministic slice of Category O6 docstring-prose-vs-
|
|
1027
|
-
implementation drift for a TYPE_CHECKING gate claim. The check reads the
|
|
1028
|
-
module docstring and every function and class docstring. It fires only when
|
|
1029
|
-
a docstring names a `TYPE_CHECKING` gate or a `type-checking-gate` helper
|
|
1030
|
-
family while no code identifier in the module carries the `type_checking`
|
|
1031
|
-
marker, so a module that genuinely gates on TYPE_CHECKING is left alone.
|
|
1032
|
-
Hook infrastructure is in scope, since the import-scan hooks that carry this
|
|
1033
|
-
drift class are themselves hooks. Test files are exempt.
|
|
1034
|
-
|
|
1035
|
-
Args:
|
|
1036
|
-
content: The source text to inspect.
|
|
1037
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
1038
|
-
|
|
1039
|
-
Returns:
|
|
1040
|
-
One issue per docstring naming a TYPE_CHECKING gate the code never
|
|
1041
|
-
performs, capped at the module limit.
|
|
1042
|
-
"""
|
|
1043
|
-
if is_strict_test_file(file_path):
|
|
1044
|
-
return []
|
|
1045
|
-
try:
|
|
1046
|
-
parsed_tree = ast.parse(content)
|
|
1047
|
-
except SyntaxError:
|
|
1048
|
-
return []
|
|
1049
|
-
if _module_code_references_type_checking(parsed_tree):
|
|
1050
|
-
return []
|
|
1051
|
-
issues: list[str] = []
|
|
1052
|
-
for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
|
|
1053
|
-
matched_phrase = _docstring_names_absent_type_checking_gate(each_docstring)
|
|
1054
|
-
if not matched_phrase:
|
|
1055
|
-
continue
|
|
1056
|
-
issues.append(
|
|
1057
|
-
f"Line {each_line_number}: docstring names a '{matched_phrase}' the module's "
|
|
1058
|
-
"code never performs — no identifier in the body handles TYPE_CHECKING, so the "
|
|
1059
|
-
"gate-detection claim is stale; drop the TYPE_CHECKING gate wording or add the "
|
|
1060
|
-
"detection (Category O6 docstring-vs-implementation drift)"
|
|
1061
|
-
)
|
|
1062
|
-
if len(issues) >= MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES:
|
|
1063
|
-
break
|
|
1064
|
-
return issues[:MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES]
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
|
|
1068
|
-
collapsed_docstring = " ".join(module_docstring.lower().split())
|
|
1069
|
-
for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
|
|
1070
|
-
if each_phrase in collapsed_docstring:
|
|
1071
|
-
return each_phrase
|
|
1072
|
-
return ""
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
def check_docstring_no_inline_literal_claim(content: str, file_path: str) -> list[str]:
|
|
1076
|
-
"""Flag a module docstring that asserts no literals appear inline elsewhere.
|
|
1077
|
-
|
|
1078
|
-
A constants-module docstring claiming "no literals appear inline in the
|
|
1079
|
-
dispatcher" makes an unverifiable completeness claim about a companion file.
|
|
1080
|
-
The claim drifts the moment a literal lands inline in that companion — a deny
|
|
1081
|
-
or block reason left inline in the dispatcher contradicts the docstring even
|
|
1082
|
-
though the constants file under edit never changed. This is the deterministic
|
|
1083
|
-
slice of Category O6 (docstring prose vs implementation drift) and a
|
|
1084
|
-
no-transitional-language violation in its own right: a docstring describes
|
|
1085
|
-
what the module holds, not the absence of literals in a sibling. Rephrase to
|
|
1086
|
-
state what the module centralizes, or drop the no-inline-literal sentence.
|
|
1087
|
-
|
|
1088
|
-
Args:
|
|
1089
|
-
content: The source text to inspect.
|
|
1090
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
1091
|
-
|
|
1092
|
-
Returns:
|
|
1093
|
-
One issue when the module docstring carries a no-inline-literal claim,
|
|
1094
|
-
capped at the module limit.
|
|
1095
|
-
"""
|
|
1096
|
-
if is_strict_test_file(file_path):
|
|
1097
|
-
return []
|
|
1098
|
-
try:
|
|
1099
|
-
parsed_tree = ast.parse(content)
|
|
1100
|
-
except SyntaxError:
|
|
1101
|
-
return []
|
|
1102
|
-
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
1103
|
-
matched_phrase = _module_docstring_claims_no_inline_literal(module_docstring)
|
|
1104
|
-
if not matched_phrase:
|
|
1105
|
-
return []
|
|
1106
|
-
issues = [
|
|
1107
|
-
f"Line 1: module docstring claims '{matched_phrase}' about a companion file "
|
|
1108
|
-
"— an unverifiable completeness claim that drifts the moment a literal lands "
|
|
1109
|
-
"inline; state what the module centralizes instead (Category O6 docstring-vs-"
|
|
1110
|
-
"implementation drift)"
|
|
1111
|
-
]
|
|
1112
|
-
return issues[:MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES]
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
498
|
def _module_docstring_summary_is_single_paragraph(module_docstring: str) -> bool:
|
|
1116
499
|
stripped_text = module_docstring.strip()
|
|
1117
500
|
if not stripped_text:
|
|
@@ -1342,896 +725,16 @@ def check_module_docstring_scope_omits_data_schema_constants(
|
|
|
1342
725
|
][:MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES]
|
|
1343
726
|
|
|
1344
727
|
|
|
1345
|
-
def
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
if (
|
|
1356
|
-
isinstance(each_element, ast.Constant)
|
|
1357
|
-
and isinstance(each_element.value, str)
|
|
1358
|
-
and IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN.match(each_element.value)
|
|
1359
|
-
):
|
|
1360
|
-
literal_members.add(each_element.value.lstrip("."))
|
|
1361
|
-
continue
|
|
1362
|
-
every_member_is_identifier_shaped = False
|
|
1363
|
-
break
|
|
1364
|
-
if not every_member_is_identifier_shaped:
|
|
1365
|
-
continue
|
|
1366
|
-
if len(literal_members) < MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION:
|
|
1367
|
-
continue
|
|
1368
|
-
for each_target in each_statement.targets:
|
|
1369
|
-
if isinstance(each_target, ast.Name):
|
|
1370
|
-
members_by_constant[each_target.id] = frozenset(literal_members)
|
|
1371
|
-
return members_by_constant
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
def _names_referenced_in_function(
|
|
1375
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1376
|
-
) -> set[str]:
|
|
1377
|
-
return {
|
|
1378
|
-
each_node.id
|
|
1379
|
-
for each_node in ast.walk(function_node)
|
|
1380
|
-
if isinstance(each_node, ast.Name)
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
def _docstring_inline_code_tokens(docstring_text: str) -> set[str]:
|
|
1385
|
-
tokens: set[str] = set()
|
|
1386
|
-
for each_match in INLINE_CODE_TOKEN_PATTERN.finditer(docstring_text):
|
|
1387
|
-
token = each_match.group(1).strip().lstrip(".")
|
|
1388
|
-
if token:
|
|
1389
|
-
tokens.add(token)
|
|
1390
|
-
return tokens
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> list[str]:
|
|
1394
|
-
"""Flag a docstring enumeration that drifts from a literal tuple it reads.
|
|
1395
|
-
|
|
1396
|
-
The drift this catches: a function reads a module-level tuple of literal
|
|
1397
|
-
string members and its docstring enumerates inline-code tokens that name
|
|
1398
|
-
some of those members, but the enumerated set and the tuple membership
|
|
1399
|
-
differ. A token the docstring lists that the tuple lacks, or a tuple member
|
|
1400
|
-
the docstring omits, misleads a reader who trusts the prose enumeration to
|
|
1401
|
-
match the detection set — the deterministic slice of Category O6
|
|
1402
|
-
docstring-prose-vs-implementation drift. The check binds only when the
|
|
1403
|
-
docstring's inline-code tokens overlap the tuple membership, so a docstring
|
|
1404
|
-
that names unrelated attributes is left alone. This check covers hook
|
|
1405
|
-
infrastructure, where the affected detection tuples live.
|
|
1406
|
-
|
|
1407
|
-
Args:
|
|
1408
|
-
content: The source text to inspect.
|
|
1409
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
1410
|
-
|
|
1411
|
-
Returns:
|
|
1412
|
-
One issue per function whose docstring enumeration diverges from the
|
|
1413
|
-
tuple it reads, capped at the module limit.
|
|
1414
|
-
"""
|
|
1415
|
-
if is_strict_test_file(file_path):
|
|
1416
|
-
return []
|
|
1417
|
-
try:
|
|
1418
|
-
parsed_tree = ast.parse(content)
|
|
1419
|
-
except SyntaxError:
|
|
1420
|
-
return []
|
|
1421
|
-
members_by_constant = _module_string_tuple_members(parsed_tree)
|
|
1422
|
-
if not members_by_constant:
|
|
1423
|
-
return []
|
|
1424
|
-
issues: list[str] = []
|
|
1425
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1426
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1427
|
-
continue
|
|
1428
|
-
docstring_text = _function_docstring_text(each_node)
|
|
1429
|
-
if not docstring_text:
|
|
1430
|
-
continue
|
|
1431
|
-
docstring_tokens = _docstring_inline_code_tokens(docstring_text)
|
|
1432
|
-
if not docstring_tokens:
|
|
1433
|
-
continue
|
|
1434
|
-
referenced_names = _names_referenced_in_function(each_node)
|
|
1435
|
-
for each_constant_name in referenced_names & set(members_by_constant):
|
|
1436
|
-
tuple_members = members_by_constant[each_constant_name]
|
|
1437
|
-
if not (docstring_tokens & tuple_members):
|
|
1438
|
-
continue
|
|
1439
|
-
if docstring_tokens == tuple_members:
|
|
1440
|
-
continue
|
|
1441
|
-
docstring_only = sorted(docstring_tokens - tuple_members)
|
|
1442
|
-
tuple_only = sorted(tuple_members - docstring_tokens)
|
|
1443
|
-
issues.append(
|
|
1444
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring enumerates "
|
|
1445
|
-
f"{sorted(docstring_tokens)} but {each_constant_name} holds "
|
|
1446
|
-
f"{sorted(tuple_members)} — docstring-only: {docstring_only}, "
|
|
1447
|
-
f"tuple-only: {tuple_only}; match the enumeration to the tuple "
|
|
1448
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
1449
|
-
)
|
|
1450
|
-
if len(issues) >= MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES:
|
|
1451
|
-
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
1452
|
-
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
def _known_glyph_marker_members(
|
|
1456
|
-
sequence_node: ast.Tuple | ast.List,
|
|
1457
|
-
) -> frozenset[str] | None:
|
|
1458
|
-
normalized_glyphs: set[str] = set()
|
|
1459
|
-
for each_element in sequence_node.elts:
|
|
1460
|
-
if not (
|
|
1461
|
-
isinstance(each_element, ast.Constant)
|
|
1462
|
-
and isinstance(each_element.value, str)
|
|
1463
|
-
):
|
|
1464
|
-
return None
|
|
1465
|
-
normalized_glyph = each_element.value.strip()
|
|
1466
|
-
if normalized_glyph not in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES:
|
|
1467
|
-
return None
|
|
1468
|
-
normalized_glyphs.add(normalized_glyph)
|
|
1469
|
-
return frozenset(normalized_glyphs)
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
def _assignment_targets_and_sequence(
|
|
1473
|
-
statement: ast.stmt,
|
|
1474
|
-
) -> tuple[list[str], ast.Tuple | ast.List | None]:
|
|
1475
|
-
if isinstance(statement, ast.Assign) and isinstance(
|
|
1476
|
-
statement.value, (ast.Tuple, ast.List)
|
|
1477
|
-
):
|
|
1478
|
-
plain_target_names = [
|
|
1479
|
-
each_target.id
|
|
1480
|
-
for each_target in statement.targets
|
|
1481
|
-
if isinstance(each_target, ast.Name)
|
|
1482
|
-
]
|
|
1483
|
-
return plain_target_names, statement.value
|
|
1484
|
-
if (
|
|
1485
|
-
isinstance(statement, ast.AnnAssign)
|
|
1486
|
-
and isinstance(statement.target, ast.Name)
|
|
1487
|
-
and isinstance(statement.value, (ast.Tuple, ast.List))
|
|
1488
|
-
):
|
|
1489
|
-
return [statement.target.id], statement.value
|
|
1490
|
-
return [], None
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
def _module_glyph_marker_tuples(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
|
|
1494
|
-
glyphs_by_constant: dict[str, frozenset[str]] = {}
|
|
1495
|
-
for each_statement in parsed_tree.body:
|
|
1496
|
-
target_names, sequence_node = _assignment_targets_and_sequence(each_statement)
|
|
1497
|
-
if sequence_node is None:
|
|
1498
|
-
continue
|
|
1499
|
-
marker_glyphs = _known_glyph_marker_members(sequence_node)
|
|
1500
|
-
if marker_glyphs is None:
|
|
1501
|
-
continue
|
|
1502
|
-
if len(marker_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
|
|
1503
|
-
continue
|
|
1504
|
-
for each_target_name in target_names:
|
|
1505
|
-
glyphs_by_constant[each_target_name] = marker_glyphs
|
|
1506
|
-
return glyphs_by_constant
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
def _companion_module_file(dotted_module: str, file_path: str) -> Path | None:
|
|
1510
|
-
relative_module_path = Path(*dotted_module.split(".")).with_suffix(
|
|
1511
|
-
PYTHON_MODULE_FILE_SUFFIX
|
|
1512
|
-
)
|
|
1513
|
-
file_directory = Path(file_path).parent
|
|
1514
|
-
candidate_roots = [file_directory, *file_directory.parents]
|
|
1515
|
-
for each_root in candidate_roots[:MAX_COMPANION_MODULE_RESOLUTION_DEPTH]:
|
|
1516
|
-
candidate_path = each_root / relative_module_path
|
|
1517
|
-
if candidate_path.is_file():
|
|
1518
|
-
return candidate_path
|
|
1519
|
-
return None
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
def _companion_glyph_marker_tuples(companion_path: Path) -> dict[str, frozenset[str]]:
|
|
1523
|
-
try:
|
|
1524
|
-
companion_source = companion_path.read_text(encoding="utf-8")
|
|
1525
|
-
companion_tree = ast.parse(companion_source)
|
|
1526
|
-
except (OSError, ValueError, SyntaxError):
|
|
1527
|
-
return {}
|
|
1528
|
-
return _module_glyph_marker_tuples(companion_tree)
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
def _import_brings_upper_snake_name(import_node: ast.ImportFrom) -> bool:
|
|
1532
|
-
return any(
|
|
1533
|
-
ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_alias.name)
|
|
1534
|
-
for each_alias in import_node.names
|
|
1535
|
-
)
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
def _imported_glyph_marker_tuples(
|
|
1539
|
-
parsed_tree: ast.Module, file_path: str
|
|
1540
|
-
) -> dict[str, frozenset[str]]:
|
|
1541
|
-
glyphs_by_imported_name: dict[str, frozenset[str]] = {}
|
|
1542
|
-
for each_statement in parsed_tree.body:
|
|
1543
|
-
if not isinstance(each_statement, ast.ImportFrom):
|
|
1544
|
-
continue
|
|
1545
|
-
if not each_statement.module:
|
|
1546
|
-
continue
|
|
1547
|
-
if not _import_brings_upper_snake_name(each_statement):
|
|
1548
|
-
continue
|
|
1549
|
-
companion_path = _companion_module_file(each_statement.module, file_path)
|
|
1550
|
-
if companion_path is None:
|
|
1551
|
-
continue
|
|
1552
|
-
companion_tuples = _companion_glyph_marker_tuples(companion_path)
|
|
1553
|
-
for each_alias in each_statement.names:
|
|
1554
|
-
if each_alias.name not in companion_tuples:
|
|
1555
|
-
continue
|
|
1556
|
-
imported_name = each_alias.asname or each_alias.name
|
|
1557
|
-
glyphs_by_imported_name[imported_name] = companion_tuples[each_alias.name]
|
|
1558
|
-
return glyphs_by_imported_name
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
def _docstring_names_mark_glyph(docstring_text: str, normalized_glyph: str) -> bool:
|
|
1562
|
-
lowercased_docstring = docstring_text.lower()
|
|
1563
|
-
for each_name in ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES[normalized_glyph]:
|
|
1564
|
-
boundary_wrapped_name = (
|
|
1565
|
-
WORD_BOUNDARY_REGEX + re.escape(each_name) + WORD_BOUNDARY_REGEX
|
|
1566
|
-
)
|
|
1567
|
-
if re.search(boundary_wrapped_name, lowercased_docstring):
|
|
1568
|
-
return True
|
|
1569
|
-
return False
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
def _marks_named_in_docstring(
|
|
1573
|
-
docstring_text: str, all_marker_glyphs: frozenset[str]
|
|
1574
|
-
) -> set[str]:
|
|
1575
|
-
return {
|
|
1576
|
-
each_glyph
|
|
1577
|
-
for each_glyph in all_marker_glyphs
|
|
1578
|
-
if _docstring_names_mark_glyph(docstring_text, each_glyph)
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
def _text_names_multiple_marks(content_text: str) -> bool:
|
|
1583
|
-
all_known_glyphs = frozenset(ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES)
|
|
1584
|
-
named_glyphs = _marks_named_in_docstring(content_text, all_known_glyphs)
|
|
1585
|
-
return len(named_glyphs) >= MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
def check_docstring_punctuation_mark_enumeration_coverage(
|
|
1589
|
-
content: str, file_path: str
|
|
1590
|
-
) -> list[str]:
|
|
1591
|
-
"""Flag a docstring that names some marks of a glyph tuple but omits one.
|
|
1592
|
-
|
|
1593
|
-
A module reads a tuple of punctuation-mark glyphs as its detection set. The
|
|
1594
|
-
tuple is defined in the module or imported from a companion module beside it.
|
|
1595
|
-
A docstring then enumerates those marks by their English names. When the
|
|
1596
|
-
prose names a closed set of marks but leaves one the tuple holds unnamed, a
|
|
1597
|
-
reader trusts the enumeration and believes an active mark never triggers the
|
|
1598
|
-
check. This is the shape that appears when a glyph joins the tuple while the
|
|
1599
|
-
prose enumeration stays as it was.
|
|
1600
|
-
|
|
1601
|
-
The check binds only when a docstring names two or more marks of one tuple. A
|
|
1602
|
-
docstring that mentions a single mark, names every mark, or describes
|
|
1603
|
-
unrelated punctuation is left alone. This is the deterministic glyph-prose
|
|
1604
|
-
slice of Category O6 docstring-prose-vs-implementation drift, the companion
|
|
1605
|
-
to check_docstring_tuple_enumeration_match for glyph members named in prose
|
|
1606
|
-
rather than identifier members named in inline code. It covers hook
|
|
1607
|
-
infrastructure, where the affected detection tuples live.
|
|
1608
|
-
|
|
1609
|
-
Args:
|
|
1610
|
-
content: The source text to inspect.
|
|
1611
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
1612
|
-
|
|
1613
|
-
Returns:
|
|
1614
|
-
One issue per docstring whose mark enumeration omits a glyph the tuple it
|
|
1615
|
-
describes holds, capped at the module limit.
|
|
1616
|
-
"""
|
|
1617
|
-
if is_strict_test_file(file_path):
|
|
1618
|
-
return []
|
|
1619
|
-
if not _text_names_multiple_marks(content):
|
|
1620
|
-
return []
|
|
1621
|
-
try:
|
|
1622
|
-
parsed_tree = ast.parse(content)
|
|
1623
|
-
except SyntaxError:
|
|
1624
|
-
return []
|
|
1625
|
-
glyphs_by_constant = {
|
|
1626
|
-
**_module_glyph_marker_tuples(parsed_tree),
|
|
1627
|
-
**_imported_glyph_marker_tuples(parsed_tree, file_path),
|
|
1628
|
-
}
|
|
1629
|
-
if not glyphs_by_constant:
|
|
1630
|
-
return []
|
|
1631
|
-
issues: list[str] = []
|
|
1632
|
-
for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
|
|
1633
|
-
for each_constant_name in sorted(glyphs_by_constant):
|
|
1634
|
-
marker_glyphs = glyphs_by_constant[each_constant_name]
|
|
1635
|
-
named_glyphs = _marks_named_in_docstring(each_docstring, marker_glyphs)
|
|
1636
|
-
if len(named_glyphs) < MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION:
|
|
1637
|
-
continue
|
|
1638
|
-
omitted_glyphs = marker_glyphs - named_glyphs
|
|
1639
|
-
if not omitted_glyphs:
|
|
1640
|
-
continue
|
|
1641
|
-
issues.append(
|
|
1642
|
-
f"Line {each_line}: docstring names {sorted(named_glyphs)} from "
|
|
1643
|
-
f"{each_constant_name} but omits {sorted(omitted_glyphs)} — name every "
|
|
1644
|
-
"mark the tuple holds so the enumeration matches the detection set "
|
|
1645
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
1646
|
-
)
|
|
1647
|
-
if len(issues) >= MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES:
|
|
1648
|
-
return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
|
|
1649
|
-
return issues[:MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES]
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
def _returns_section_text(docstring_text: str) -> str:
|
|
1653
|
-
docstring_lines = docstring_text.splitlines()
|
|
1654
|
-
returns_section_lines: list[str] = []
|
|
1655
|
-
inside_returns_section = False
|
|
1656
|
-
for each_line in docstring_lines:
|
|
1657
|
-
stripped_line = each_line.strip()
|
|
1658
|
-
if stripped_line in ("Returns:", "Yields:"):
|
|
1659
|
-
inside_returns_section = True
|
|
1660
|
-
continue
|
|
1661
|
-
if not inside_returns_section:
|
|
1662
|
-
continue
|
|
1663
|
-
if _is_docstring_terminating_section_header(stripped_line):
|
|
1664
|
-
break
|
|
1665
|
-
returns_section_lines.append(stripped_line)
|
|
1666
|
-
return " ".join(returns_section_lines)
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
def _plural_families_in_returns_section(returns_section_text: str) -> set[str]:
|
|
1670
|
-
return {
|
|
1671
|
-
each_match.group(1)
|
|
1672
|
-
for each_match in DOCSTRING_PLURAL_FAMILY_STOP_PATTERN.finditer(returns_section_text)
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
def _returned_dict_key_names(
|
|
1677
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1678
|
-
) -> set[str]:
|
|
1679
|
-
all_key_names: set[str] = set()
|
|
1680
|
-
for each_node in ast.walk(function_node):
|
|
1681
|
-
if not isinstance(each_node, ast.Return):
|
|
1682
|
-
continue
|
|
1683
|
-
if not isinstance(each_node.value, ast.Dict):
|
|
1684
|
-
continue
|
|
1685
|
-
for each_key in each_node.value.keys:
|
|
1686
|
-
if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
|
|
1687
|
-
all_key_names.add(each_key.value)
|
|
1688
|
-
return all_key_names
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
def _family_prefixed_key_count(family: str, all_key_names: set[str]) -> int:
|
|
1692
|
-
family_prefix = f"{family}_"
|
|
1693
|
-
return sum(1 for each_key in all_key_names if each_key.startswith(family_prefix))
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
def check_docstring_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
|
|
1697
|
-
"""Flag a Returns clause plural noun that names one dict key in its family.
|
|
1698
|
-
|
|
1699
|
-
The drift this catches: a function returns a dict literal whose keys carry
|
|
1700
|
-
prefix families (``sheen_mid``, ``body_highlight``), and its Returns clause
|
|
1701
|
-
names one family with a plural noun (``the sheen stops``) while exactly one
|
|
1702
|
-
key in that family exists. The plural prose claims two or more entries the
|
|
1703
|
-
dict no longer holds — the shape that appears when a producer removes the
|
|
1704
|
-
second key in a family but leaves the plural prose untouched. The check binds
|
|
1705
|
-
only when the plural family prefixes exactly one returned dict key, so a
|
|
1706
|
-
singular noun, a family with two or more keys, and a family absent from the
|
|
1707
|
-
dict are all left alone. This is the deterministic single-key slice of
|
|
1708
|
-
Category O6 docstring-prose-vs-implementation drift.
|
|
1709
|
-
|
|
1710
|
-
Args:
|
|
1711
|
-
content: The source text to inspect.
|
|
1712
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
1713
|
-
|
|
1714
|
-
Returns:
|
|
1715
|
-
One issue per function whose Returns clause names a plural family that
|
|
1716
|
-
prefixes a single returned dict key, capped at the module limit.
|
|
1717
|
-
"""
|
|
1718
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1719
|
-
return []
|
|
1720
|
-
try:
|
|
1721
|
-
parsed_tree = ast.parse(content)
|
|
1722
|
-
except SyntaxError:
|
|
1723
|
-
return []
|
|
1724
|
-
issues: list[str] = []
|
|
1725
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1726
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1727
|
-
continue
|
|
1728
|
-
docstring_text = _function_docstring_text(each_node)
|
|
1729
|
-
if not docstring_text:
|
|
1730
|
-
continue
|
|
1731
|
-
returns_section_text = _returns_section_text(docstring_text)
|
|
1732
|
-
if not returns_section_text:
|
|
1733
|
-
continue
|
|
1734
|
-
plural_families = _plural_families_in_returns_section(returns_section_text)
|
|
1735
|
-
if not plural_families:
|
|
1736
|
-
continue
|
|
1737
|
-
all_key_names = _returned_dict_key_names(each_node)
|
|
1738
|
-
for each_family in sorted(plural_families):
|
|
1739
|
-
matching_key_count = _family_prefixed_key_count(each_family, all_key_names)
|
|
1740
|
-
if matching_key_count != SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT:
|
|
1741
|
-
continue
|
|
1742
|
-
issues.append(
|
|
1743
|
-
f"Line {each_node.lineno}: {each_node.name}() Returns clause says "
|
|
1744
|
-
f"'the {each_family} stops' (plural) but the returned dict holds a "
|
|
1745
|
-
f"single {each_family}_ key — match the noun to the cardinality "
|
|
1746
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
1747
|
-
)
|
|
1748
|
-
if len(issues) >= MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES:
|
|
1749
|
-
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1750
|
-
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
def _module_level_length_constant_names(parsed_tree: ast.Module) -> set[str]:
|
|
1754
|
-
all_length_constant_names: set[str] = set()
|
|
1755
|
-
for each_statement in parsed_tree.body:
|
|
1756
|
-
target_names: list[str] = []
|
|
1757
|
-
assigned_value: ast.expr | None = None
|
|
1758
|
-
if isinstance(each_statement, ast.Assign):
|
|
1759
|
-
assigned_value = each_statement.value
|
|
1760
|
-
target_names = [
|
|
1761
|
-
each_target.id
|
|
1762
|
-
for each_target in each_statement.targets
|
|
1763
|
-
if isinstance(each_target, ast.Name)
|
|
1764
|
-
]
|
|
1765
|
-
elif isinstance(each_statement, ast.AnnAssign) and isinstance(
|
|
1766
|
-
each_statement.target, ast.Name
|
|
1767
|
-
):
|
|
1768
|
-
assigned_value = each_statement.value
|
|
1769
|
-
target_names = [each_statement.target.id]
|
|
1770
|
-
if not isinstance(assigned_value, ast.Constant) or not isinstance(
|
|
1771
|
-
assigned_value.value, int
|
|
1772
|
-
):
|
|
1773
|
-
continue
|
|
1774
|
-
for each_name in target_names:
|
|
1775
|
-
if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_name):
|
|
1776
|
-
continue
|
|
1777
|
-
if each_name.endswith(ALL_LENGTH_CONSTANT_NAME_SUFFIXES):
|
|
1778
|
-
all_length_constant_names.add(each_name)
|
|
1779
|
-
return all_length_constant_names
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
def _superlative_phrase_by_length_constant(
|
|
1783
|
-
parsed_tree: ast.Module, all_length_constant_names: set[str]
|
|
1784
|
-
) -> tuple[dict[str, str], str]:
|
|
1785
|
-
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
1786
|
-
phrase_by_constant: dict[str, str] = {}
|
|
1787
|
-
unattributed_phrase = ""
|
|
1788
|
-
for each_sentence in DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN.split(module_docstring):
|
|
1789
|
-
lowered_sentence = each_sentence.lower()
|
|
1790
|
-
sentence_phrase = next(
|
|
1791
|
-
(
|
|
1792
|
-
each_phrase
|
|
1793
|
-
for each_phrase in ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES
|
|
1794
|
-
if each_phrase in lowered_sentence
|
|
1795
|
-
),
|
|
1796
|
-
"",
|
|
1797
|
-
)
|
|
1798
|
-
if not sentence_phrase:
|
|
1799
|
-
continue
|
|
1800
|
-
named_constants = {
|
|
1801
|
-
each_name
|
|
1802
|
-
for each_name in all_length_constant_names
|
|
1803
|
-
if each_name in each_sentence
|
|
1804
|
-
}
|
|
1805
|
-
if named_constants:
|
|
1806
|
-
for each_name in named_constants:
|
|
1807
|
-
phrase_by_constant.setdefault(each_name, sentence_phrase)
|
|
1808
|
-
elif not unattributed_phrase:
|
|
1809
|
-
unattributed_phrase = sentence_phrase
|
|
1810
|
-
return phrase_by_constant, unattributed_phrase
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
def _compare_targets_a_length_constant(
|
|
1814
|
-
compare_node: ast.Compare, all_length_constant_names: set[str]
|
|
1815
|
-
) -> str:
|
|
1816
|
-
operands = [compare_node.left, *compare_node.comparators]
|
|
1817
|
-
has_length_call = any(
|
|
1818
|
-
isinstance(each_operand, ast.Call)
|
|
1819
|
-
and isinstance(each_operand.func, ast.Name)
|
|
1820
|
-
and each_operand.func.id == "len"
|
|
1821
|
-
for each_operand in operands
|
|
1822
|
-
)
|
|
1823
|
-
if not has_length_call:
|
|
1824
|
-
return ""
|
|
1825
|
-
for each_operand in operands:
|
|
1826
|
-
if isinstance(each_operand, ast.Name) and each_operand.id in all_length_constant_names:
|
|
1827
|
-
return each_operand.id
|
|
1828
|
-
return ""
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
def _length_gate_comparison_kinds_in_tree(
|
|
1832
|
-
parsed_tree: ast.Module, all_length_constant_names: set[str]
|
|
1833
|
-
) -> dict[str, set[str]]:
|
|
1834
|
-
comparison_kinds_by_constant: dict[str, set[str]] = {}
|
|
1835
|
-
for each_node in ast.walk(parsed_tree):
|
|
1836
|
-
if not isinstance(each_node, ast.Compare):
|
|
1837
|
-
continue
|
|
1838
|
-
compared_constant = _compare_targets_a_length_constant(
|
|
1839
|
-
each_node, all_length_constant_names
|
|
1840
|
-
)
|
|
1841
|
-
if not compared_constant:
|
|
1842
|
-
continue
|
|
1843
|
-
for each_operator in each_node.ops:
|
|
1844
|
-
kind = (
|
|
1845
|
-
"equality"
|
|
1846
|
-
if isinstance(each_operator, (ast.Eq, ast.NotEq))
|
|
1847
|
-
else "ordered"
|
|
1848
|
-
)
|
|
1849
|
-
comparison_kinds_by_constant.setdefault(compared_constant, set()).add(kind)
|
|
1850
|
-
return comparison_kinds_by_constant
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
def _package_scan_python_files(file_path: str) -> list[Path]:
|
|
1854
|
-
file = Path(file_path)
|
|
1855
|
-
scan_root = (
|
|
1856
|
-
file.parent.parent
|
|
1857
|
-
if file.parent.name == LENGTH_CONFIG_SUBDIRECTORY_NAME
|
|
1858
|
-
else file.parent
|
|
1859
|
-
)
|
|
1860
|
-
try:
|
|
1861
|
-
all_python_files = sorted(scan_root.rglob(f"*{PYTHON_MODULE_FILE_SUFFIX}"))
|
|
1862
|
-
except OSError:
|
|
1863
|
-
return []
|
|
1864
|
-
return [
|
|
1865
|
-
each_file
|
|
1866
|
-
for each_file in all_python_files[:LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT]
|
|
1867
|
-
if each_file.resolve() != file.resolve()
|
|
1868
|
-
and not is_test_file(str(each_file))
|
|
1869
|
-
]
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
def _exact_length_gate_constants_in_package(
|
|
1873
|
-
file_path: str, all_length_constant_names: set[str]
|
|
1874
|
-
) -> set[str]:
|
|
1875
|
-
aggregate_kinds: dict[str, set[str]] = {}
|
|
1876
|
-
for each_file in _package_scan_python_files(file_path):
|
|
1877
|
-
try:
|
|
1878
|
-
sibling_tree = ast.parse(each_file.read_text(encoding="utf-8"))
|
|
1879
|
-
except (OSError, SyntaxError, ValueError):
|
|
1880
|
-
continue
|
|
1881
|
-
for each_constant, each_kinds in _length_gate_comparison_kinds_in_tree(
|
|
1882
|
-
sibling_tree, all_length_constant_names
|
|
1883
|
-
).items():
|
|
1884
|
-
aggregate_kinds.setdefault(each_constant, set()).update(each_kinds)
|
|
1885
|
-
return {
|
|
1886
|
-
each_constant
|
|
1887
|
-
for each_constant, each_kinds in aggregate_kinds.items()
|
|
1888
|
-
if "equality" in each_kinds and "ordered" not in each_kinds
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
def check_docstring_length_constant_superlative_vs_exact_gate(
|
|
1893
|
-
content: str, file_path: str
|
|
1894
|
-
) -> list[str]:
|
|
1895
|
-
"""Flag a length-constant docstring that calls an exact gate the "longest" form.
|
|
1896
|
-
|
|
1897
|
-
The drift this catches: a config module defines an integer ``*_LENGTH``
|
|
1898
|
-
constant and its docstring describes that constant with a superlative or
|
|
1899
|
-
range word (``the longest color string the swatch accepts``), while the only
|
|
1900
|
-
code that consumes the constant treats it as an exact-length equality gate
|
|
1901
|
-
(``len(hex_color) != COLOR_AARRGGBB_LENGTH``) — a shorter string is rejected,
|
|
1902
|
-
not accepted at a shorter length. The superlative prose implies a range of
|
|
1903
|
-
accepted lengths the code never allows. The consumer lives in a sibling
|
|
1904
|
-
module, so the check scans the constant module's package tree (its own
|
|
1905
|
-
directory, or its parent package when the module sits in a ``config/``
|
|
1906
|
-
subdirectory) for the comparison; it binds only when a length constant is
|
|
1907
|
-
compared with ``==``/``!=`` against ``len(...)`` somewhere in that tree and
|
|
1908
|
-
never with an ordered operator, so a constant genuinely used as a ceiling
|
|
1909
|
-
(``len(x) <= LIMIT``) is left alone. The superlative phrase is read from the
|
|
1910
|
-
module docstring only, and it is bound to a constant before flagging: a
|
|
1911
|
-
constant is flagged when the docstring sentence carrying the phrase names
|
|
1912
|
-
that constant, or when the phrase sits in a sentence naming no length
|
|
1913
|
-
constant and the docstring never names the constant elsewhere — so a phrase
|
|
1914
|
-
describing one constant (``MAX_NAME_LENGTH is the longest label``) does not
|
|
1915
|
-
flag a different exact-gated constant documented on its own terms. This is
|
|
1916
|
-
the deterministic exact-gate slice of Category O6/O8
|
|
1917
|
-
docstring-vs-implementation drift; the cross-module free-prose variant where
|
|
1918
|
-
the docstring never names the constant stays an audit-lane finding.
|
|
1919
|
-
|
|
1920
|
-
Args:
|
|
1921
|
-
content: The source text to inspect.
|
|
1922
|
-
file_path: The path the source will be written to, used for exemptions
|
|
1923
|
-
and to locate the package tree the consumer lives in.
|
|
1924
|
-
|
|
1925
|
-
Returns:
|
|
1926
|
-
One issue per length constant the package exact-gates while the module
|
|
1927
|
-
docstring binds a superlative phrase to that constant, capped at the
|
|
1928
|
-
module limit.
|
|
1929
|
-
"""
|
|
1930
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1931
|
-
return []
|
|
1932
|
-
try:
|
|
1933
|
-
parsed_tree = ast.parse(content)
|
|
1934
|
-
except SyntaxError:
|
|
1935
|
-
return []
|
|
1936
|
-
all_length_constant_names = _module_level_length_constant_names(parsed_tree)
|
|
1937
|
-
if not all_length_constant_names:
|
|
1938
|
-
return []
|
|
1939
|
-
phrase_by_constant, unattributed_phrase = _superlative_phrase_by_length_constant(
|
|
1940
|
-
parsed_tree, all_length_constant_names
|
|
1941
|
-
)
|
|
1942
|
-
if not phrase_by_constant and not unattributed_phrase:
|
|
1943
|
-
return []
|
|
1944
|
-
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
1945
|
-
named_length_constants = {
|
|
1946
|
-
each_name for each_name in all_length_constant_names if each_name in module_docstring
|
|
1947
|
-
}
|
|
1948
|
-
exact_gate_constants = _exact_length_gate_constants_in_package(
|
|
1949
|
-
file_path, all_length_constant_names
|
|
1950
|
-
)
|
|
1951
|
-
issues: list[str] = []
|
|
1952
|
-
for each_constant in sorted(exact_gate_constants):
|
|
1953
|
-
bound_phrase = phrase_by_constant.get(each_constant, "")
|
|
1954
|
-
if not bound_phrase and each_constant not in named_length_constants:
|
|
1955
|
-
bound_phrase = unattributed_phrase
|
|
1956
|
-
if not bound_phrase:
|
|
1957
|
-
continue
|
|
1958
|
-
issues.append(
|
|
1959
|
-
f"Line 1: module docstring says '{bound_phrase}' about "
|
|
1960
|
-
f"{each_constant}, but the package compares len(...) against it only "
|
|
1961
|
-
"with ==/!= (an exact-length gate that rejects every other length) — "
|
|
1962
|
-
"state the exact required length, not a longest/maximum range "
|
|
1963
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
1964
|
-
)
|
|
1965
|
-
if len(issues) >= MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES:
|
|
1966
|
-
return issues[:MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES]
|
|
1967
|
-
return issues[:MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES]
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
def _referenced_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
|
|
1971
|
-
members_by_family: dict[str, set[str]] = {}
|
|
1972
|
-
for each_node in ast.walk(parsed_tree):
|
|
1973
|
-
if not isinstance(each_node, ast.Name):
|
|
1974
|
-
continue
|
|
1975
|
-
if not isinstance(each_node.ctx, ast.Load):
|
|
1976
|
-
continue
|
|
1977
|
-
if not ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_node.id):
|
|
1978
|
-
continue
|
|
1979
|
-
family_prefix, _, member_suffix = each_node.id.partition("_")
|
|
1980
|
-
if not member_suffix:
|
|
1981
|
-
continue
|
|
1982
|
-
members_by_family.setdefault(family_prefix, set()).add(member_suffix.lower())
|
|
1983
|
-
return members_by_family
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
def _eligible_constant_families(parsed_tree: ast.Module) -> dict[str, set[str]]:
|
|
1987
|
-
return {
|
|
1988
|
-
each_family: each_members
|
|
1989
|
-
for each_family, each_members in _referenced_constant_families(parsed_tree).items()
|
|
1990
|
-
if len(each_members) >= MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
def _docstring_snake_case_tokens(docstring_text: str) -> set[str]:
|
|
1995
|
-
return set(DOCSTRING_MULTI_SEGMENT_SNAKE_TOKEN_PATTERN.findall(docstring_text))
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
def _docstring_stated_outcome_cardinals(docstring_text: str) -> set[int]:
|
|
1999
|
-
return {
|
|
2000
|
-
ALL_CARDINAL_NUMBER_WORD_VALUES[each_match.group(1).lower()]
|
|
2001
|
-
for each_match in DOCSTRING_CARDINAL_OUTCOME_PHRASE_PATTERN.finditer(docstring_text)
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[int, str]]:
|
|
2006
|
-
docstrings_with_line: list[tuple[int, str]] = []
|
|
2007
|
-
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
2008
|
-
if module_docstring:
|
|
2009
|
-
docstrings_with_line.append((1, module_docstring))
|
|
2010
|
-
for each_node in ast.walk(parsed_tree):
|
|
2011
|
-
if not isinstance(
|
|
2012
|
-
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
2013
|
-
):
|
|
2014
|
-
continue
|
|
2015
|
-
node_docstring = ast.get_docstring(each_node) or ""
|
|
2016
|
-
if node_docstring:
|
|
2017
|
-
docstrings_with_line.append((each_node.lineno, node_docstring))
|
|
2018
|
-
return docstrings_with_line
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
def check_docstring_cardinal_count_matches_constant_family(
|
|
2022
|
-
content: str, file_path: str
|
|
2023
|
-
) -> list[str]:
|
|
2024
|
-
"""Flag a docstring cardinal count that differs from a referenced constant family.
|
|
2025
|
-
|
|
2026
|
-
The drift this catches: a docstring states a cardinal count of an outcome
|
|
2027
|
-
family (``Covers the four outcome branches: ...``) and enumerates that
|
|
2028
|
-
family's members in prose, while the module references a member set of the
|
|
2029
|
-
same ``UPPER_SNAKE`` constant family whose size differs from the stated
|
|
2030
|
-
count — above it and below it both trip. The prose
|
|
2031
|
-
misdescribes the code — a reader trusts the count and the enumeration to
|
|
2032
|
-
be the full set, but the module imports and exercises an outcome the summary
|
|
2033
|
-
omits. The check binds only when the docstring states a cardinal beside an
|
|
2034
|
-
outcome noun, names two or more members of one referenced family, leaves at
|
|
2035
|
-
least one referenced family member unnamed, and the family member count
|
|
2036
|
-
differs from every stated cardinal; a complete enumeration, a count that
|
|
2037
|
-
matches the family, and a single passing mention are all left alone. This is
|
|
2038
|
-
the deterministic cardinal-count slice of Category O6
|
|
2039
|
-
docstring-prose-vs-implementation drift. The check runs on test files too,
|
|
2040
|
-
since the drift class lives in a test-module summary.
|
|
2041
|
-
|
|
2042
|
-
Args:
|
|
2043
|
-
content: The source text to inspect.
|
|
2044
|
-
file_path: Accepted for the uniform check interface; the body does not
|
|
2045
|
-
read it.
|
|
2046
|
-
|
|
2047
|
-
Returns:
|
|
2048
|
-
One issue per docstring whose cardinal count and enumeration omit a
|
|
2049
|
-
referenced constant-family member, capped at the module limit.
|
|
2050
|
-
"""
|
|
2051
|
-
try:
|
|
2052
|
-
parsed_tree = ast.parse(content)
|
|
2053
|
-
except SyntaxError:
|
|
2054
|
-
return []
|
|
2055
|
-
eligible_families = _eligible_constant_families(parsed_tree)
|
|
2056
|
-
if not eligible_families:
|
|
2057
|
-
return []
|
|
2058
|
-
issues: list[str] = []
|
|
2059
|
-
for each_line, each_docstring in _documentable_docstrings_with_line(parsed_tree):
|
|
2060
|
-
stated_cardinals = _docstring_stated_outcome_cardinals(each_docstring)
|
|
2061
|
-
if not stated_cardinals:
|
|
2062
|
-
continue
|
|
2063
|
-
docstring_tokens = _docstring_snake_case_tokens(each_docstring)
|
|
2064
|
-
if not docstring_tokens:
|
|
2065
|
-
continue
|
|
2066
|
-
for each_family in sorted(eligible_families):
|
|
2067
|
-
family_members = eligible_families[each_family]
|
|
2068
|
-
named_members = docstring_tokens & family_members
|
|
2069
|
-
if len(named_members) < MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK:
|
|
2070
|
-
continue
|
|
2071
|
-
omitted_members = family_members - docstring_tokens
|
|
2072
|
-
if not omitted_members:
|
|
2073
|
-
continue
|
|
2074
|
-
if len(family_members) in stated_cardinals:
|
|
2075
|
-
continue
|
|
2076
|
-
issues.append(
|
|
2077
|
-
f"Line {each_line}: docstring names {sorted(stated_cardinals)} as the "
|
|
2078
|
-
f"{each_family}_ count but the module references {len(family_members)} "
|
|
2079
|
-
f"{each_family}_ constants — omits {sorted(omitted_members)}; match the "
|
|
2080
|
-
"count and enumeration to the referenced family "
|
|
2081
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
2082
|
-
)
|
|
2083
|
-
if len(issues) >= MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES:
|
|
2084
|
-
return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
|
|
2085
|
-
return issues[:MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES]
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
def _called_callee_name(statement: ast.stmt) -> str:
|
|
2089
|
-
candidate_expression: ast.expr | None = None
|
|
2090
|
-
if isinstance(statement, ast.Expr):
|
|
2091
|
-
candidate_expression = statement.value
|
|
2092
|
-
elif isinstance(statement, ast.Assign):
|
|
2093
|
-
candidate_expression = statement.value
|
|
2094
|
-
elif isinstance(statement, ast.AnnAssign):
|
|
2095
|
-
candidate_expression = statement.value
|
|
2096
|
-
if candidate_expression is None:
|
|
2097
|
-
return ""
|
|
2098
|
-
if isinstance(candidate_expression, ast.Await):
|
|
2099
|
-
candidate_expression = candidate_expression.value
|
|
2100
|
-
if not isinstance(candidate_expression, ast.Call):
|
|
2101
|
-
return ""
|
|
2102
|
-
return _call_callee_name(candidate_expression)
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
def _called_callees_in_expression(expression: ast.expr) -> set[str]:
|
|
2106
|
-
callees: set[str] = set()
|
|
2107
|
-
for each_descendant in ast.walk(expression):
|
|
2108
|
-
if not isinstance(each_descendant, ast.Call):
|
|
2109
|
-
continue
|
|
2110
|
-
callee_name = _call_callee_name(each_descendant)
|
|
2111
|
-
if callee_name:
|
|
2112
|
-
callees.add(callee_name)
|
|
2113
|
-
return callees
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
def _linear_step_callees(
|
|
2117
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2118
|
-
) -> set[str]:
|
|
2119
|
-
callees: set[str] = set()
|
|
2120
|
-
for each_statement in function_node.body:
|
|
2121
|
-
callee_name = _called_callee_name(each_statement)
|
|
2122
|
-
if callee_name:
|
|
2123
|
-
callees.add(callee_name)
|
|
2124
|
-
if isinstance(each_statement, ast.If):
|
|
2125
|
-
callees |= _called_callees_in_expression(each_statement.test)
|
|
2126
|
-
return callees
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
def _branch_guarded_dispatch_callees(
|
|
2130
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2131
|
-
) -> set[str]:
|
|
2132
|
-
callees: set[str] = set()
|
|
2133
|
-
for each_statement in function_node.body:
|
|
2134
|
-
if not isinstance(each_statement, ast.If):
|
|
2135
|
-
continue
|
|
2136
|
-
for each_branch_statement in each_statement.body + each_statement.orelse:
|
|
2137
|
-
for each_descendant in ast.walk(each_branch_statement):
|
|
2138
|
-
if not isinstance(each_descendant, ast.If):
|
|
2139
|
-
continue
|
|
2140
|
-
callees |= _called_callees_in_expression(each_descendant.test)
|
|
2141
|
-
return callees
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
def _bare_callee_name(qualified_callee: str) -> str:
|
|
2145
|
-
return qualified_callee.rsplit(".", 1)[-1]
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
def _docstring_names_all_callee_tokens(docstring_text: str, callee_name: str) -> bool:
|
|
2149
|
-
bare_name = _bare_callee_name(callee_name)
|
|
2150
|
-
lowered_docstring = docstring_text.lower()
|
|
2151
|
-
if bare_name.lower() in lowered_docstring:
|
|
2152
|
-
return True
|
|
2153
|
-
callee_tokens = _name_tokens(bare_name)
|
|
2154
|
-
if not callee_tokens:
|
|
2155
|
-
return False
|
|
2156
|
-
return all(each_token.lower() in lowered_docstring for each_token in callee_tokens)
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
def check_docstring_step_enumeration_dispatch_coverage(
|
|
2160
|
-
content: str, file_path: str
|
|
2161
|
-
) -> list[str]:
|
|
2162
|
-
"""Flag a step-enumeration docstring that omits a conditional dispatch call.
|
|
2163
|
-
|
|
2164
|
-
The drift this catches: a function whose docstring enumerates a linear
|
|
2165
|
-
sequence of steps (``Navigates ..., searches ..., clicks ..., uploads ...``)
|
|
2166
|
-
matching the body's linear-step calls, while the body also routes to a
|
|
2167
|
-
corrective workflow step inside an ``if``/``elif`` branch — a cancel-and-reinitiate
|
|
2168
|
-
or replace-target-row step — whose name the prose never spells out. A reader
|
|
2169
|
-
who trusts the step list to be complete misses that the function can take that
|
|
2170
|
-
conditional path. This is the deterministic slice of Category O4 (step-ordering
|
|
2171
|
-
narrative): a body that guards a branch-only workflow step the enumeration omits.
|
|
2172
|
-
|
|
2173
|
-
A linear-step call is one made as a top-level statement or inside the ``If.test``
|
|
2174
|
-
guard of a top-level ``if`` (``if not await self.navigate(): return``). A
|
|
2175
|
-
dispatch step is a call inside a guard (``If.test``) nested within an
|
|
2176
|
-
``if``/``elif`` branch (``if not await cancel_and_reinitiate_update(...): return``)
|
|
2177
|
-
that is never also a linear step — the same control-flow-gating shape as a
|
|
2178
|
-
linear step, so plain (unguarded) logging, screenshot, or method-on-local
|
|
2179
|
-
calls inside a branch body are not dispatch steps. The check binds only when
|
|
2180
|
-
the docstring already names two or more linear-step callees by their
|
|
2181
|
-
underscore tokens, proving the prose is a step enumeration describing this
|
|
2182
|
-
body. A dispatch-step callee with two or more underscore tokens, none of
|
|
2183
|
-
whose tokens appear in the prose, is flagged.
|
|
2184
|
-
|
|
2185
|
-
Args:
|
|
2186
|
-
content: The source text to inspect.
|
|
2187
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
2188
|
-
|
|
2189
|
-
Returns:
|
|
2190
|
-
One issue per conditional dispatch call the step enumeration omits, capped
|
|
2191
|
-
at the module limit.
|
|
2192
|
-
"""
|
|
2193
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
2194
|
-
return []
|
|
2195
|
-
try:
|
|
2196
|
-
parsed_tree = ast.parse(content)
|
|
2197
|
-
except SyntaxError:
|
|
2198
|
-
return []
|
|
2199
|
-
issues: list[str] = []
|
|
2200
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
2201
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2202
|
-
continue
|
|
2203
|
-
if _function_has_exempt_decorator(each_node):
|
|
2204
|
-
continue
|
|
2205
|
-
docstring_text = _function_docstring_text(each_node)
|
|
2206
|
-
if not docstring_text:
|
|
2207
|
-
continue
|
|
2208
|
-
linear_step_callees = _linear_step_callees(each_node)
|
|
2209
|
-
named_linear_steps = [
|
|
2210
|
-
each_callee
|
|
2211
|
-
for each_callee in linear_step_callees
|
|
2212
|
-
if _docstring_names_all_callee_tokens(docstring_text, each_callee)
|
|
2213
|
-
]
|
|
2214
|
-
if len(named_linear_steps) < MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK:
|
|
2215
|
-
continue
|
|
2216
|
-
branch_only_callees = (
|
|
2217
|
-
_branch_guarded_dispatch_callees(each_node) - linear_step_callees
|
|
2218
|
-
)
|
|
2219
|
-
for each_callee in sorted(branch_only_callees):
|
|
2220
|
-
if len(_name_tokens(_bare_callee_name(each_callee))) < MINIMUM_TOKENS_FOR_DISPATCH_CALLEE:
|
|
2221
|
-
continue
|
|
2222
|
-
if _docstring_names_all_callee_tokens(docstring_text, each_callee):
|
|
2223
|
-
continue
|
|
2224
|
-
issues.append(
|
|
2225
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring enumerates linear "
|
|
2226
|
-
f"steps but omits the conditional dispatch step {each_callee}() the body "
|
|
2227
|
-
"guards inside a branch — add the corrective-path step to the enumeration "
|
|
2228
|
-
"(Category O4 step-ordering narrative drift)"
|
|
2229
|
-
)
|
|
2230
|
-
if len(issues) >= MAX_DOCSTRING_STEP_DISPATCH_ISSUES:
|
|
2231
|
-
return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
|
|
2232
|
-
return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
|
|
2233
|
-
|
|
2234
|
-
|
|
728
|
+
def _names_referenced_in_function(
|
|
729
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
730
|
+
) -> set[str]:
|
|
731
|
+
return {
|
|
732
|
+
each_node.id
|
|
733
|
+
for each_node in ast.walk(function_node)
|
|
734
|
+
if isinstance(each_node, ast.Name)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
|
|
2235
738
|
def _imported_binding_names(import_node: ast.Import | ast.ImportFrom) -> set[str]:
|
|
2236
739
|
bound_names: set[str] = set()
|
|
2237
740
|
for each_alias in import_node.names:
|
|
@@ -2434,135 +937,6 @@ def check_docstring_names_undefined_constant(content: str, file_path: str) -> li
|
|
|
2434
937
|
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|
|
2435
938
|
|
|
2436
939
|
|
|
2437
|
-
def _args_entry_prose_by_argument(docstring_text: str) -> dict[str, str]:
|
|
2438
|
-
docstring_lines = docstring_text.splitlines()
|
|
2439
|
-
args_section_index = _find_args_section_index(docstring_lines)
|
|
2440
|
-
if args_section_index is None:
|
|
2441
|
-
return {}
|
|
2442
|
-
prose_by_argument: dict[str, list[str]] = {}
|
|
2443
|
-
current_argument_name: str | None = None
|
|
2444
|
-
entry_indent: int | None = None
|
|
2445
|
-
for each_line in docstring_lines[args_section_index + 1:]:
|
|
2446
|
-
stripped_line = each_line.strip()
|
|
2447
|
-
if not stripped_line:
|
|
2448
|
-
continue
|
|
2449
|
-
if _is_docstring_terminating_section_header(stripped_line):
|
|
2450
|
-
break
|
|
2451
|
-
current_indent = len(each_line) - len(each_line.lstrip())
|
|
2452
|
-
if current_indent == 0:
|
|
2453
|
-
break
|
|
2454
|
-
if entry_indent is None:
|
|
2455
|
-
entry_indent = current_indent
|
|
2456
|
-
if current_indent <= entry_indent:
|
|
2457
|
-
entry_match = DOCSTRING_ARG_ENTRY_PATTERN.match(stripped_line)
|
|
2458
|
-
if entry_match is not None:
|
|
2459
|
-
current_argument_name = entry_match.group(1)
|
|
2460
|
-
prose_by_argument[current_argument_name] = [stripped_line]
|
|
2461
|
-
continue
|
|
2462
|
-
if current_argument_name is not None:
|
|
2463
|
-
prose_by_argument[current_argument_name].append(stripped_line)
|
|
2464
|
-
return {
|
|
2465
|
-
each_name: " ".join(each_lines).lower()
|
|
2466
|
-
for each_name, each_lines in prose_by_argument.items()
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
|
|
2471
|
-
if any(
|
|
2472
|
-
each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES
|
|
2473
|
-
):
|
|
2474
|
-
return False
|
|
2475
|
-
return any(
|
|
2476
|
-
each_phrase in argument_prose for each_phrase in ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES
|
|
2477
|
-
)
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
def _call_node_builds_explicit_start_range(call_node: ast.Call) -> bool:
|
|
2481
|
-
callee = call_node.func
|
|
2482
|
-
return (
|
|
2483
|
-
isinstance(callee, ast.Name)
|
|
2484
|
-
and callee.id == "range"
|
|
2485
|
-
and len(call_node.args) >= MINIMUM_RANGE_ARGS_FOR_SPAN
|
|
2486
|
-
)
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
def _function_body_scopes_a_span_by_intersection(
|
|
2490
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2491
|
-
) -> bool:
|
|
2492
|
-
builds_range_span = False
|
|
2493
|
-
calls_span_scoper = False
|
|
2494
|
-
for each_node in ast.walk(function_node):
|
|
2495
|
-
if not isinstance(each_node, ast.Call):
|
|
2496
|
-
continue
|
|
2497
|
-
if _call_node_builds_explicit_start_range(each_node):
|
|
2498
|
-
builds_range_span = True
|
|
2499
|
-
if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
|
|
2500
|
-
calls_span_scoper = True
|
|
2501
|
-
return builds_range_span and calls_span_scoper
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str) -> list[str]:
|
|
2505
|
-
"""Flag a docstring Args entry scoping a finding to one line over a span body.
|
|
2506
|
-
|
|
2507
|
-
The drift this catches: an ``Args:`` entry whose prose says a finding blocks
|
|
2508
|
-
"only when its block-anchor line is among the changed lines" (a single named
|
|
2509
|
-
line) while the function body builds a ``range(...)`` span over the finding's
|
|
2510
|
-
source lines and routes it through a span-intersection scoper that blocks when
|
|
2511
|
-
ANY line of the span is among the changed lines. The Args sentence claims a
|
|
2512
|
-
narrower single-line scope than the body applies, so an edit touching a
|
|
2513
|
-
non-anchor line of the span still blocks — contradicting the Args entry. This
|
|
2514
|
-
is the deterministic slice of Category O6 docstring-vs-implementation drift
|
|
2515
|
-
for an Args single-line scope claim disagreeing with a span-intersection body.
|
|
2516
|
-
|
|
2517
|
-
An entry is left alone when its prose says "any line of" / "any line in" /
|
|
2518
|
-
"any of its lines" / "any span line" about its span, since that wording
|
|
2519
|
-
matches the span body. The body is judged a span-intersection scoper only
|
|
2520
|
-
when it both builds an explicit-start ``range(...)`` (two or more
|
|
2521
|
-
arguments) and calls a known span scoper, so a body that scopes by a
|
|
2522
|
-
single line never trips the check. Hook infrastructure is in scope here — the import-sort gate
|
|
2523
|
-
that carries this drift class is itself a hook — and test files are exempt.
|
|
2524
|
-
|
|
2525
|
-
Args:
|
|
2526
|
-
content: The source text to inspect.
|
|
2527
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
2528
|
-
|
|
2529
|
-
Returns:
|
|
2530
|
-
One issue per function whose Args single-line scope claim disagrees with
|
|
2531
|
-
its span-intersection body, capped at the module limit.
|
|
2532
|
-
"""
|
|
2533
|
-
if is_test_file(file_path):
|
|
2534
|
-
return []
|
|
2535
|
-
try:
|
|
2536
|
-
parsed_tree = ast.parse(content)
|
|
2537
|
-
except SyntaxError:
|
|
2538
|
-
return []
|
|
2539
|
-
issues: list[str] = []
|
|
2540
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
2541
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
2542
|
-
continue
|
|
2543
|
-
if _function_has_exempt_decorator(each_node):
|
|
2544
|
-
continue
|
|
2545
|
-
docstring_text = _function_docstring_text(each_node)
|
|
2546
|
-
if not docstring_text:
|
|
2547
|
-
continue
|
|
2548
|
-
if not _function_body_scopes_a_span_by_intersection(each_node):
|
|
2549
|
-
continue
|
|
2550
|
-
for each_argument, each_prose in _args_entry_prose_by_argument(docstring_text).items():
|
|
2551
|
-
if not _argument_prose_scopes_a_single_line(each_prose):
|
|
2552
|
-
continue
|
|
2553
|
-
issues.append(
|
|
2554
|
-
f"Line {each_node.lineno}: {each_node.name}() Args entry '{each_argument}' "
|
|
2555
|
-
"scopes a finding to a single line ('anchor line is among the changed lines') "
|
|
2556
|
-
"while the body builds a range() span and scopes by span intersection — an "
|
|
2557
|
-
"edit touching any non-anchor line of the span still blocks; restate the Args "
|
|
2558
|
-
"entry as 'any line of its span is among the changed lines' (Category O6 "
|
|
2559
|
-
"docstring-vs-implementation drift)"
|
|
2560
|
-
)
|
|
2561
|
-
if len(issues) >= MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES:
|
|
2562
|
-
return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
|
|
2563
|
-
return issues[:MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES]
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
940
|
def _is_narrative_cut_header(stripped_line: str) -> bool:
|
|
2567
941
|
return (
|
|
2568
942
|
stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
|
|
@@ -2883,505 +1257,3 @@ def check_docstring_prose_wall_without_illustration(
|
|
|
2883
1257
|
return scoped_issues[:MAX_DOCSTRING_PROSE_WALL_ISSUES]
|
|
2884
1258
|
|
|
2885
1259
|
|
|
2886
|
-
def _raises_section_text(docstring_text: str) -> str:
|
|
2887
|
-
docstring_lines = docstring_text.splitlines()
|
|
2888
|
-
raises_section_lines: list[str] = []
|
|
2889
|
-
inside_raises_section = False
|
|
2890
|
-
for each_line in docstring_lines:
|
|
2891
|
-
stripped_line = each_line.strip()
|
|
2892
|
-
if stripped_line == "Raises:":
|
|
2893
|
-
inside_raises_section = True
|
|
2894
|
-
continue
|
|
2895
|
-
if not inside_raises_section:
|
|
2896
|
-
continue
|
|
2897
|
-
if _is_docstring_terminating_section_header(stripped_line):
|
|
2898
|
-
break
|
|
2899
|
-
raises_section_lines.append(stripped_line)
|
|
2900
|
-
return " ".join(raises_section_lines)
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
def _call_argument_by_keyword_or_position(
|
|
2904
|
-
call_node: ast.Call, keyword_name: str, positional_index: int
|
|
2905
|
-
) -> ast.expr | None:
|
|
2906
|
-
for each_keyword in call_node.keywords:
|
|
2907
|
-
if each_keyword.arg == keyword_name:
|
|
2908
|
-
return each_keyword.value
|
|
2909
|
-
if positional_index >= len(call_node.args):
|
|
2910
|
-
return None
|
|
2911
|
-
if any(
|
|
2912
|
-
isinstance(each_argument, ast.Starred)
|
|
2913
|
-
for each_argument in call_node.args[: positional_index + 1]
|
|
2914
|
-
):
|
|
2915
|
-
return None
|
|
2916
|
-
return call_node.args[positional_index]
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
def _call_opens_zipfile_write_mode_writer(call_node: ast.Call) -> bool:
|
|
2920
|
-
callee = call_node.func
|
|
2921
|
-
if isinstance(callee, ast.Attribute):
|
|
2922
|
-
callee_name = callee.attr
|
|
2923
|
-
elif isinstance(callee, ast.Name):
|
|
2924
|
-
callee_name = callee.id
|
|
2925
|
-
else:
|
|
2926
|
-
return False
|
|
2927
|
-
if callee_name != ZIPFILE_WRITER_CLASS_NAME:
|
|
2928
|
-
return False
|
|
2929
|
-
mode_argument = _call_argument_by_keyword_or_position(
|
|
2930
|
-
call_node, ZIPFILE_MODE_KEYWORD, ZIPFILE_MODE_POSITIONAL_INDEX
|
|
2931
|
-
)
|
|
2932
|
-
return (
|
|
2933
|
-
isinstance(mode_argument, ast.Constant)
|
|
2934
|
-
and mode_argument.value in ALL_ZIPFILE_WRITE_MODE_VALUES
|
|
2935
|
-
)
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
def _zipfile_writer_forbids_zip64(call_node: ast.Call) -> bool:
|
|
2939
|
-
allow_zip64_argument = _call_argument_by_keyword_or_position(
|
|
2940
|
-
call_node, ZIPFILE_ALLOW_ZIP64_KEYWORD, ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX
|
|
2941
|
-
)
|
|
2942
|
-
return (
|
|
2943
|
-
isinstance(allow_zip64_argument, ast.Constant)
|
|
2944
|
-
and allow_zip64_argument.value is False
|
|
2945
|
-
)
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
def _function_documents_unraisable_largezipfile(
|
|
2949
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
2950
|
-
docstring_text: str,
|
|
2951
|
-
) -> bool:
|
|
2952
|
-
if DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME not in _raises_section_text(docstring_text):
|
|
2953
|
-
return False
|
|
2954
|
-
write_mode_writers = [
|
|
2955
|
-
each_descendant
|
|
2956
|
-
for each_descendant in _walk_skipping_nested_functions(function_node)
|
|
2957
|
-
if isinstance(each_descendant, ast.Call)
|
|
2958
|
-
and _call_opens_zipfile_write_mode_writer(each_descendant)
|
|
2959
|
-
]
|
|
2960
|
-
if not write_mode_writers:
|
|
2961
|
-
return False
|
|
2962
|
-
return not any(
|
|
2963
|
-
_zipfile_writer_forbids_zip64(each_writer) for each_writer in write_mode_writers
|
|
2964
|
-
)
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
def _attributes_section_entries(docstring_text: str) -> list[tuple[str, str]]:
|
|
2968
|
-
"""Return each ``name: description`` entry under an Attributes section.
|
|
2969
|
-
|
|
2970
|
-
A continuation line indented under an entry joins that entry's description,
|
|
2971
|
-
so a field whose description wraps across lines reads as one string. The
|
|
2972
|
-
scan ends at the next Google-style section header or at the docstring end.
|
|
2973
|
-
|
|
2974
|
-
Args:
|
|
2975
|
-
docstring_text: The full class docstring text to scan.
|
|
2976
|
-
|
|
2977
|
-
Returns:
|
|
2978
|
-
One ``(field_name, description)`` pair per Attributes entry, in order.
|
|
2979
|
-
"""
|
|
2980
|
-
all_lines = docstring_text.splitlines()
|
|
2981
|
-
inside_section = False
|
|
2982
|
-
entries: list[tuple[str, str]] = []
|
|
2983
|
-
for each_line in all_lines:
|
|
2984
|
-
stripped_line = each_line.strip()
|
|
2985
|
-
if not inside_section:
|
|
2986
|
-
if stripped_line in ("Attributes:", "Attrs:"):
|
|
2987
|
-
inside_section = True
|
|
2988
|
-
continue
|
|
2989
|
-
if stripped_line.endswith(":") and not each_line.startswith((" ", "\t")):
|
|
2990
|
-
break
|
|
2991
|
-
if stripped_line in ALL_DOCSTRING_TERMINATING_SECTION_HEADERS:
|
|
2992
|
-
break
|
|
2993
|
-
entry_match = re.match(
|
|
2994
|
-
r"^\s+(?P<field_name>[A-Za-z_][A-Za-z0-9_]*):\s*(?P<description>.*)$",
|
|
2995
|
-
each_line,
|
|
2996
|
-
)
|
|
2997
|
-
if entry_match:
|
|
2998
|
-
entries.append(
|
|
2999
|
-
(entry_match.group("field_name"), entry_match.group("description"))
|
|
3000
|
-
)
|
|
3001
|
-
elif entries and stripped_line:
|
|
3002
|
-
previous_name, previous_description = entries[-1]
|
|
3003
|
-
entries[-1] = (previous_name, f"{previous_description} {stripped_line}")
|
|
3004
|
-
return entries
|
|
3005
|
-
|
|
3006
|
-
def _field_name_is_run_mode_flag(field_name: str) -> bool:
|
|
3007
|
-
lowered_name = field_name.lower()
|
|
3008
|
-
return any(
|
|
3009
|
-
each_token in lowered_name
|
|
3010
|
-
for each_token in ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS
|
|
3011
|
-
)
|
|
3012
|
-
|
|
3013
|
-
def _description_claims_per_record_write_outcome(description: str) -> bool:
|
|
3014
|
-
lowered_description = description.lower()
|
|
3015
|
-
has_write_outcome_phrase = any(
|
|
3016
|
-
each_phrase in lowered_description
|
|
3017
|
-
for each_phrase in ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES
|
|
3018
|
-
)
|
|
3019
|
-
has_run_mode_phrase = any(
|
|
3020
|
-
each_phrase in lowered_description
|
|
3021
|
-
for each_phrase in ALL_DOCSTRING_RUN_MODE_PHRASES
|
|
3022
|
-
)
|
|
3023
|
-
return has_write_outcome_phrase and not has_run_mode_phrase
|
|
3024
|
-
|
|
3025
|
-
def check_docstring_field_runmode_outcome(content: str, file_path: str) -> list[str]:
|
|
3026
|
-
"""Flag a run-mode flag field documented as a per-record write outcome.
|
|
3027
|
-
|
|
3028
|
-
The drift this catches: a dataclass or TypedDict field whose name marks a
|
|
3029
|
-
run-mode flag (``is_dry_run``) is documented in the class Attributes block
|
|
3030
|
-
with per-record write-outcome prose (``True when no STP was written``),
|
|
3031
|
-
while the value is set the same way for every record from the run mode
|
|
3032
|
-
(``is_dry_run=not is_execute``). An already-OK record in an execute run then
|
|
3033
|
-
writes no file yet still stores ``False``, so the per-record prose misleads
|
|
3034
|
-
every reader. The check binds only when the description carries a
|
|
3035
|
-
write-outcome phrase and no run-mode phrase, so a field documented by its
|
|
3036
|
-
run-mode meaning is left alone. This is the deterministic single-file slice
|
|
3037
|
-
of Category O6 run-mode-versus-per-record docstring drift.
|
|
3038
|
-
|
|
3039
|
-
Args:
|
|
3040
|
-
content: The source text to inspect.
|
|
3041
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
3042
|
-
|
|
3043
|
-
Returns:
|
|
3044
|
-
One issue per run-mode flag field whose Attributes description claims a
|
|
3045
|
-
per-record write outcome, capped at the module limit.
|
|
3046
|
-
"""
|
|
3047
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
3048
|
-
return []
|
|
3049
|
-
try:
|
|
3050
|
-
parsed_tree = ast.parse(content)
|
|
3051
|
-
except SyntaxError:
|
|
3052
|
-
return []
|
|
3053
|
-
issues: list[str] = []
|
|
3054
|
-
for each_node in ast.walk(parsed_tree):
|
|
3055
|
-
if not isinstance(each_node, ast.ClassDef):
|
|
3056
|
-
continue
|
|
3057
|
-
docstring_text = ast.get_docstring(each_node) or ""
|
|
3058
|
-
if not docstring_text:
|
|
3059
|
-
continue
|
|
3060
|
-
for each_field_name, each_description in _attributes_section_entries(docstring_text):
|
|
3061
|
-
if not _field_name_is_run_mode_flag(each_field_name):
|
|
3062
|
-
continue
|
|
3063
|
-
if not _description_claims_per_record_write_outcome(each_description):
|
|
3064
|
-
continue
|
|
3065
|
-
issues.append(
|
|
3066
|
-
f"Line {each_node.lineno}: {each_node.name}.{each_field_name} is a "
|
|
3067
|
-
"run-mode flag but its Attributes description claims a per-record "
|
|
3068
|
-
"write outcome — restate it as the run-mode meaning the assignment "
|
|
3069
|
-
"gives it (Category O6 run-mode-versus-per-record docstring drift)"
|
|
3070
|
-
)
|
|
3071
|
-
if len(issues) >= MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES:
|
|
3072
|
-
return issues[:MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES]
|
|
3073
|
-
return issues[:MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES]
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
def check_docstring_raises_unraisable_largezipfile(
|
|
3077
|
-
content: str, file_path: str
|
|
3078
|
-
) -> list[str]:
|
|
3079
|
-
"""Flag a Raises clause naming LargeZipFile over a default-ZIP64 writer.
|
|
3080
|
-
|
|
3081
|
-
The drift this catches: a function whose docstring Raises clause lists
|
|
3082
|
-
``zipfile.LargeZipFile`` while the function opens its ``zipfile.ZipFile``
|
|
3083
|
-
writer in a write mode (``w``/``a``/``x``) with ``allowZip64`` left at its
|
|
3084
|
-
default of True. The stdlib raises ``LargeZipFile`` only when an entry needs
|
|
3085
|
-
ZIP64 AND ``allowZip64`` is False; with ZIP64 permitted the writer
|
|
3086
|
-
transparently uses it and never raises. The Raises entry then documents an
|
|
3087
|
-
exception the body cannot produce, so a caller guarding ``LargeZipFile`` on
|
|
3088
|
-
the strength of the docstring guards an unreachable path. This is the
|
|
3089
|
-
deterministic slice of Category O6 docstring-prose-vs-implementation drift
|
|
3090
|
-
where a writer opened with default ZIP64 disagrees with a LargeZipFile
|
|
3091
|
-
Raises clause.
|
|
3092
|
-
|
|
3093
|
-
The check binds only when the function opens at least one write-mode
|
|
3094
|
-
``ZipFile`` and every such writer permits ZIP64, so a function that forbids
|
|
3095
|
-
ZIP64 on any writer (``allowZip64=False``, by keyword or position), a
|
|
3096
|
-
read-only open, and a function that opens no writer — where the exception may
|
|
3097
|
-
propagate from a callee — are all left alone.
|
|
3098
|
-
|
|
3099
|
-
Args:
|
|
3100
|
-
content: The source text to inspect.
|
|
3101
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
3102
|
-
|
|
3103
|
-
Returns:
|
|
3104
|
-
One issue per function whose LargeZipFile Raises clause names an
|
|
3105
|
-
unreachable exception, capped at the module limit.
|
|
3106
|
-
"""
|
|
3107
|
-
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
3108
|
-
return []
|
|
3109
|
-
try:
|
|
3110
|
-
parsed_tree = ast.parse(content)
|
|
3111
|
-
except SyntaxError:
|
|
3112
|
-
return []
|
|
3113
|
-
issues: list[str] = []
|
|
3114
|
-
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
3115
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
3116
|
-
continue
|
|
3117
|
-
if _function_has_exempt_decorator(each_node):
|
|
3118
|
-
continue
|
|
3119
|
-
docstring_text = _function_docstring_text(each_node)
|
|
3120
|
-
if not docstring_text:
|
|
3121
|
-
continue
|
|
3122
|
-
if not _function_documents_unraisable_largezipfile(each_node, docstring_text):
|
|
3123
|
-
continue
|
|
3124
|
-
issues.append(
|
|
3125
|
-
f"Line {each_node.lineno}: {each_node.name}() docstring Raises lists "
|
|
3126
|
-
"zipfile.LargeZipFile, but the function opens its ZipFile writer with ZIP64 "
|
|
3127
|
-
"permitted (allowZip64 defaults to True) — LargeZipFile raises only when "
|
|
3128
|
-
"allowZip64 is False, so drop the entry or pass allowZip64=False "
|
|
3129
|
-
"(Category O6 docstring-vs-implementation drift)"
|
|
3130
|
-
)
|
|
3131
|
-
if len(issues) >= MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES:
|
|
3132
|
-
break
|
|
3133
|
-
return issues[:MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES]
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
def _leading_summary_line(raw_docstring: str) -> str:
|
|
3137
|
-
summary_paragraph = raw_docstring.strip().split("\n\n", maxsplit=1)[0]
|
|
3138
|
-
return " ".join(summary_paragraph.split())
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
def _pointer_target_stem(raw_docstring: str) -> str:
|
|
3142
|
-
marker_match = POINTER_TO_DELEGATE_PATTERN.search(raw_docstring)
|
|
3143
|
-
return marker_match.group(1) if marker_match is not None else ""
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
def _listed_entries(wrapper_summary: str) -> list[str]:
|
|
3147
|
-
marker_match = POINTER_TO_DELEGATE_PATTERN.search(wrapper_summary)
|
|
3148
|
-
if marker_match is None:
|
|
3149
|
-
return []
|
|
3150
|
-
listed_text = wrapper_summary[: marker_match.start()].strip().rstrip(";,").strip()
|
|
3151
|
-
normalized_text = re.sub(r"\s+and\s+", ", ", listed_text, flags=re.IGNORECASE)
|
|
3152
|
-
return [
|
|
3153
|
-
each_entry.strip()
|
|
3154
|
-
for each_entry in normalized_text.split(",")
|
|
3155
|
-
if each_entry.strip()
|
|
3156
|
-
]
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
def _words_of_entry(raw_entry: str) -> list[str]:
|
|
3160
|
-
return [
|
|
3161
|
-
each_token.lower()
|
|
3162
|
-
for each_token in TOKEN_WORD_PATTERN.findall(raw_entry)
|
|
3163
|
-
if each_token.lower() not in ALL_DELEGATION_ENUMERATION_STOP_TOKENS
|
|
3164
|
-
]
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
def _matches_any_word(delegate_summary: str, all_distinct_words: list[str]) -> bool:
|
|
3168
|
-
lowered_summary = delegate_summary.lower()
|
|
3169
|
-
return any(
|
|
3170
|
-
re.search(
|
|
3171
|
-
WORD_BOUNDARY_REGEX + re.escape(each_token) + WORD_BOUNDARY_REGEX,
|
|
3172
|
-
lowered_summary,
|
|
3173
|
-
)
|
|
3174
|
-
is not None
|
|
3175
|
-
for each_token in all_distinct_words
|
|
3176
|
-
)
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
def _absent_entries(wrapper_summary: str, delegate_summary: str) -> list[str]:
|
|
3180
|
-
all_entries = _listed_entries(wrapper_summary)
|
|
3181
|
-
if len(all_entries) < MINIMUM_DELEGATION_ENUMERATION_ITEMS:
|
|
3182
|
-
return []
|
|
3183
|
-
if "," not in delegate_summary:
|
|
3184
|
-
return []
|
|
3185
|
-
absent_entry_texts: list[str] = []
|
|
3186
|
-
for each_entry in all_entries:
|
|
3187
|
-
distinct_words = _words_of_entry(each_entry)
|
|
3188
|
-
if not distinct_words:
|
|
3189
|
-
continue
|
|
3190
|
-
if _matches_any_word(delegate_summary, distinct_words):
|
|
3191
|
-
continue
|
|
3192
|
-
absent_entry_texts.append(each_entry)
|
|
3193
|
-
return absent_entry_texts
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
def _top_level_definitions_and_methods(
|
|
3197
|
-
parsed_tree: ast.Module,
|
|
3198
|
-
) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
3199
|
-
all_definitions_and_methods: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
3200
|
-
for each_node in parsed_tree.body:
|
|
3201
|
-
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
3202
|
-
all_definitions_and_methods.append(each_node)
|
|
3203
|
-
elif isinstance(each_node, ast.ClassDef):
|
|
3204
|
-
all_definitions_and_methods.extend(
|
|
3205
|
-
each_member
|
|
3206
|
-
for each_member in each_node.body
|
|
3207
|
-
if isinstance(each_member, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
3208
|
-
)
|
|
3209
|
-
return all_definitions_and_methods
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
def _entries_by_function_name(parsed_tree: ast.Module) -> dict[str, tuple[int, str]]:
|
|
3213
|
-
entries_by_name: dict[str, tuple[int, str]] = {}
|
|
3214
|
-
ambiguous_names: set[str] = set()
|
|
3215
|
-
for each_definition in _top_level_definitions_and_methods(parsed_tree):
|
|
3216
|
-
if each_definition.name in entries_by_name:
|
|
3217
|
-
ambiguous_names.add(each_definition.name)
|
|
3218
|
-
continue
|
|
3219
|
-
entries_by_name[each_definition.name] = (
|
|
3220
|
-
each_definition.lineno,
|
|
3221
|
-
ast.get_docstring(each_definition) or "",
|
|
3222
|
-
)
|
|
3223
|
-
for each_ambiguous_name in ambiguous_names:
|
|
3224
|
-
del entries_by_name[each_ambiguous_name]
|
|
3225
|
-
return entries_by_name
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
def _source_text_or_empty(source_path: Path) -> str:
|
|
3229
|
-
try:
|
|
3230
|
-
return source_path.read_text(encoding="utf-8")
|
|
3231
|
-
except (OSError, UnicodeDecodeError):
|
|
3232
|
-
return ""
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
def _parsed_module_or_none(source_text: str) -> ast.Module | None:
|
|
3236
|
-
if not source_text:
|
|
3237
|
-
return None
|
|
3238
|
-
try:
|
|
3239
|
-
return ast.parse(source_text)
|
|
3240
|
-
except SyntaxError:
|
|
3241
|
-
return None
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
def _outbound_pointer_issues(parsed_tree: ast.Module, file_path: str) -> list[str]:
|
|
3245
|
-
parent_directory = Path(file_path).parent
|
|
3246
|
-
issues: list[str] = []
|
|
3247
|
-
delegate_entries_by_stem: dict[str, dict[str, tuple[int, str]]] = {}
|
|
3248
|
-
for each_node in _top_level_definitions_and_methods(parsed_tree):
|
|
3249
|
-
wrapper_docstring = ast.get_docstring(each_node) or ""
|
|
3250
|
-
wrapper_summary = _leading_summary_line(wrapper_docstring)
|
|
3251
|
-
target_stem = _pointer_target_stem(wrapper_summary)
|
|
3252
|
-
if not target_stem:
|
|
3253
|
-
continue
|
|
3254
|
-
if target_stem not in delegate_entries_by_stem:
|
|
3255
|
-
delegate_path = parent_directory / (target_stem + PYTHON_MODULE_FILE_SUFFIX)
|
|
3256
|
-
delegate_tree = _parsed_module_or_none(_source_text_or_empty(delegate_path))
|
|
3257
|
-
delegate_entries_by_stem[target_stem] = (
|
|
3258
|
-
_entries_by_function_name(delegate_tree)
|
|
3259
|
-
if delegate_tree is not None
|
|
3260
|
-
else {}
|
|
3261
|
-
)
|
|
3262
|
-
delegate_entry = delegate_entries_by_stem[target_stem].get(each_node.name)
|
|
3263
|
-
if delegate_entry is None:
|
|
3264
|
-
continue
|
|
3265
|
-
delegate_summary = _leading_summary_line(delegate_entry[1])
|
|
3266
|
-
for each_entry in _absent_entries(wrapper_summary, delegate_summary):
|
|
3267
|
-
issues.append(
|
|
3268
|
-
f"Line {each_node.lineno}: {each_node.name}() names "
|
|
3269
|
-
f"'{each_entry}' in its summary, but the summary of the "
|
|
3270
|
-
f"same-named function in {target_stem} omits it - align the "
|
|
3271
|
-
"two summaries (Category O6 docstring-vs-implementation drift)"
|
|
3272
|
-
)
|
|
3273
|
-
return issues
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
def _scannable_neighbor_paths(written_path: Path) -> list[Path]:
|
|
3277
|
-
try:
|
|
3278
|
-
all_neighbor_paths = sorted(
|
|
3279
|
-
written_path.parent.glob("*" + PYTHON_MODULE_FILE_SUFFIX)
|
|
3280
|
-
)
|
|
3281
|
-
except OSError:
|
|
3282
|
-
return []
|
|
3283
|
-
scannable_paths: list[Path] = []
|
|
3284
|
-
for each_path in all_neighbor_paths:
|
|
3285
|
-
if each_path.name == written_path.name:
|
|
3286
|
-
continue
|
|
3287
|
-
if is_strict_test_file(str(each_path)):
|
|
3288
|
-
continue
|
|
3289
|
-
scannable_paths.append(each_path)
|
|
3290
|
-
if len(scannable_paths) >= NEIGHBOR_SCAN_FILE_LIMIT:
|
|
3291
|
-
break
|
|
3292
|
-
return scannable_paths
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
def _inbound_pointer_issues(
|
|
3296
|
-
neighbor_tree: ast.Module,
|
|
3297
|
-
neighbor_name: str,
|
|
3298
|
-
delegate_stem: str,
|
|
3299
|
-
all_entries_by_name: dict[str, tuple[int, str]],
|
|
3300
|
-
) -> list[str]:
|
|
3301
|
-
issues: list[str] = []
|
|
3302
|
-
for each_node in _top_level_definitions_and_methods(neighbor_tree):
|
|
3303
|
-
wrapper_docstring = ast.get_docstring(each_node) or ""
|
|
3304
|
-
wrapper_summary = _leading_summary_line(wrapper_docstring)
|
|
3305
|
-
if _pointer_target_stem(wrapper_summary) != delegate_stem:
|
|
3306
|
-
continue
|
|
3307
|
-
delegate_entry = all_entries_by_name.get(each_node.name)
|
|
3308
|
-
if delegate_entry is None:
|
|
3309
|
-
continue
|
|
3310
|
-
delegate_summary = _leading_summary_line(delegate_entry[1])
|
|
3311
|
-
for each_entry in _absent_entries(wrapper_summary, delegate_summary):
|
|
3312
|
-
issues.append(
|
|
3313
|
-
f"Line {delegate_entry[0]}: {each_node.name}() summary omits "
|
|
3314
|
-
f"'{each_entry}', named by the pointing wrapper docstring in "
|
|
3315
|
-
f"{neighbor_name} - reword that wrapper docstring in the same "
|
|
3316
|
-
"change (Category O6 docstring-vs-implementation drift)"
|
|
3317
|
-
)
|
|
3318
|
-
return issues
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
def _neighbor_scan_issues(parsed_tree: ast.Module, file_path: str) -> list[str]:
|
|
3322
|
-
written_path = Path(file_path)
|
|
3323
|
-
all_entries_by_name = _entries_by_function_name(parsed_tree)
|
|
3324
|
-
if not all_entries_by_name:
|
|
3325
|
-
return []
|
|
3326
|
-
issues: list[str] = []
|
|
3327
|
-
for each_neighbor in _scannable_neighbor_paths(written_path):
|
|
3328
|
-
neighbor_source = _source_text_or_empty(each_neighbor)
|
|
3329
|
-
if POINTER_TO_DELEGATE_PATTERN.search(neighbor_source) is None:
|
|
3330
|
-
continue
|
|
3331
|
-
neighbor_tree = _parsed_module_or_none(neighbor_source)
|
|
3332
|
-
if neighbor_tree is None:
|
|
3333
|
-
continue
|
|
3334
|
-
issues.extend(
|
|
3335
|
-
_inbound_pointer_issues(
|
|
3336
|
-
neighbor_tree,
|
|
3337
|
-
each_neighbor.name,
|
|
3338
|
-
written_path.stem,
|
|
3339
|
-
all_entries_by_name,
|
|
3340
|
-
)
|
|
3341
|
-
)
|
|
3342
|
-
return issues
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
def check_docstring_delegation_summary_enumeration_drift(
|
|
3346
|
-
content: str, file_path: str
|
|
3347
|
-
) -> list[str]:
|
|
3348
|
-
"""Flag a delegating wrapper summary that its delegation target contradicts.
|
|
3349
|
-
|
|
3350
|
-
The drift this catches: a thin wrapper method whose docstring summary
|
|
3351
|
-
enumerates the actions it stands for and points at the home of the real
|
|
3352
|
-
body ("Apply App Info, Russia, review note, publication edits; full doc
|
|
3353
|
-
on `listing_edit_flow`"), while the same-named function in that named
|
|
3354
|
-
sibling file carries a summary enumeration that omits one of those
|
|
3355
|
-
actions. An edit that moves one action out of the delegated body and
|
|
3356
|
-
rewords only the delegated docstring leaves the wrapper claiming work the
|
|
3357
|
-
body skips. The gate fires from both sides. On a wrapper save, it resolves
|
|
3358
|
-
the named sibling file beside the saved one, finds the same-named
|
|
3359
|
-
function, and compares the two summary enumerations. On a delegated-body
|
|
3360
|
-
save, it scans neighboring .py files for a wrapper docstring pointing at
|
|
3361
|
-
the saved stem and runs the same comparison, so the save that removes the
|
|
3362
|
-
action hears about the stranded wrapper docstring. A listed action counts
|
|
3363
|
-
as named when any of its distinctive words appears at a word boundary in
|
|
3364
|
-
the delegated summary. The comparison binds when the wrapper enumerates
|
|
3365
|
-
two or more actions and the delegated summary itself holds a comma, so a
|
|
3366
|
-
plain one-purpose delegated summary never gets compared. This covers the
|
|
3367
|
-
deterministic delegating-wrapper slice of Category O6
|
|
3368
|
-
docstring-prose-vs-implementation drift.
|
|
3369
|
-
|
|
3370
|
-
Args:
|
|
3371
|
-
content: The source text to inspect.
|
|
3372
|
-
file_path: The path the source will be written to, used for exemptions
|
|
3373
|
-
and to resolve the delegated or wrapping file beside it.
|
|
3374
|
-
|
|
3375
|
-
Returns:
|
|
3376
|
-
One issue per listed wrapper action the paired delegated summary
|
|
3377
|
-
omits, capped at the module limit.
|
|
3378
|
-
"""
|
|
3379
|
-
if is_strict_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
3380
|
-
return []
|
|
3381
|
-
try:
|
|
3382
|
-
parsed_tree = ast.parse(content)
|
|
3383
|
-
except SyntaxError:
|
|
3384
|
-
return []
|
|
3385
|
-
issues = _outbound_pointer_issues(parsed_tree, file_path)
|
|
3386
|
-
issues.extend(_neighbor_scan_issues(parsed_tree, file_path))
|
|
3387
|
-
return issues[:MAX_DOCSTRING_DELEGATION_ENUMERATION_ISSUES]
|