claude-dev-env 1.72.0 → 1.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Google-style docstring presence and docstring Args-versus-signature checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import re
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -16,29 +17,53 @@ from code_rules_shared import ( # noqa: E402
|
|
|
16
17
|
_walk_skipping_nested_functions,
|
|
17
18
|
_walk_skipping_type_checking_blocks,
|
|
18
19
|
is_hook_infrastructure,
|
|
20
|
+
is_strict_test_file,
|
|
19
21
|
is_test_file,
|
|
20
22
|
)
|
|
21
23
|
|
|
22
24
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
23
25
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
|
|
24
26
|
ALL_DOCSTRING_EXEMPT_DECORATOR_NAMES,
|
|
27
|
+
ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES,
|
|
28
|
+
ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES,
|
|
25
29
|
ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
|
|
26
30
|
ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
|
|
27
31
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
|
|
32
|
+
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
|
|
33
|
+
ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
|
|
34
|
+
ALL_GENERIC_CHECK_NAME_TOKENS,
|
|
35
|
+
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
|
|
28
36
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
|
|
37
|
+
DOCSTRING_REFERENCE_MARKER_WINDOW,
|
|
29
38
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
|
|
30
39
|
MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
|
|
31
40
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
32
41
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
|
|
33
42
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
43
|
+
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
|
|
34
44
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
|
|
45
|
+
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
46
|
+
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES,
|
|
47
|
+
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
48
|
+
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
49
|
+
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
|
|
50
|
+
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
|
|
51
|
+
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
|
|
52
|
+
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
35
53
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
54
|
+
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
|
|
55
|
+
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
|
|
56
|
+
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
|
|
36
57
|
)
|
|
37
58
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
59
|
+
ALL_CAPS_WITH_UNDERSCORE_PATTERN,
|
|
38
60
|
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
39
61
|
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
40
62
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
41
63
|
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
64
|
+
DOCSTRING_PLURAL_FAMILY_STOP_PATTERN,
|
|
65
|
+
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
|
|
66
|
+
INLINE_CODE_TOKEN_PATTERN,
|
|
42
67
|
)
|
|
43
68
|
|
|
44
69
|
|
|
@@ -619,3 +644,817 @@ def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]
|
|
|
619
644
|
if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
|
|
620
645
|
break
|
|
621
646
|
return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _docstring_claims_malformed_payload_is_guarded(docstring_text: str) -> str:
|
|
650
|
+
collapsed_docstring = " ".join(docstring_text.lower().split())
|
|
651
|
+
for each_phrase in ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES:
|
|
652
|
+
if each_phrase in collapsed_docstring:
|
|
653
|
+
return each_phrase
|
|
654
|
+
return ""
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _try_handler_returns_none(try_node: ast.Try) -> bool:
|
|
658
|
+
for each_handler in try_node.handlers:
|
|
659
|
+
for each_statement in each_handler.body:
|
|
660
|
+
if isinstance(each_statement, ast.Return) and isinstance(
|
|
661
|
+
each_statement.value, ast.Constant
|
|
662
|
+
):
|
|
663
|
+
if each_statement.value.value is None:
|
|
664
|
+
return True
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _names_bound_in_try_body(try_node: ast.Try) -> set[str]:
|
|
669
|
+
all_bound_names: set[str] = set()
|
|
670
|
+
for each_statement in try_node.body:
|
|
671
|
+
for each_descendant in ast.walk(each_statement):
|
|
672
|
+
if isinstance(each_descendant, ast.Name) and isinstance(
|
|
673
|
+
each_descendant.ctx, ast.Store
|
|
674
|
+
):
|
|
675
|
+
all_bound_names.add(each_descendant.id)
|
|
676
|
+
return all_bound_names
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _statement_subscripts_one_of(
|
|
680
|
+
statement: ast.stmt, all_payload_names: set[str]
|
|
681
|
+
) -> bool:
|
|
682
|
+
for each_descendant in ast.walk(statement):
|
|
683
|
+
if (
|
|
684
|
+
isinstance(each_descendant, ast.Subscript)
|
|
685
|
+
and isinstance(each_descendant.value, ast.Name)
|
|
686
|
+
and each_descendant.value.id in all_payload_names
|
|
687
|
+
):
|
|
688
|
+
return True
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _function_has_unguarded_payload_dereference(
|
|
693
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
694
|
+
) -> bool:
|
|
695
|
+
all_payload_names: set[str] = set()
|
|
696
|
+
saw_returning_guard = False
|
|
697
|
+
for each_statement in function_node.body:
|
|
698
|
+
if isinstance(each_statement, ast.Try) and _try_handler_returns_none(
|
|
699
|
+
each_statement
|
|
700
|
+
):
|
|
701
|
+
all_payload_names |= _names_bound_in_try_body(each_statement)
|
|
702
|
+
saw_returning_guard = True
|
|
703
|
+
continue
|
|
704
|
+
if not saw_returning_guard:
|
|
705
|
+
continue
|
|
706
|
+
if _statement_subscripts_one_of(each_statement, all_payload_names):
|
|
707
|
+
return True
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def check_docstring_unguarded_malformed_payload_claim(
|
|
712
|
+
content: str, file_path: str
|
|
713
|
+
) -> list[str]:
|
|
714
|
+
"""Flag a docstring that promises malformed-payload safety the guard misses.
|
|
715
|
+
|
|
716
|
+
The drift this catches: a function whose docstring states that a malformed
|
|
717
|
+
payload "resolves to None" while a subscript dereference of that payload
|
|
718
|
+
(``payload["key"]``, ``float(payload["key"])``) sits OUTSIDE the try/except
|
|
719
|
+
whose handler returns None. A present-but-malformed payload then raises
|
|
720
|
+
KeyError or TypeError from that unguarded access and propagates rather than
|
|
721
|
+
resolving to None, so the docstring overstates the protection. This is the
|
|
722
|
+
deterministic slice of Category O6 (docstring prose vs implementation drift)
|
|
723
|
+
for an exception-guard claim: move the dereference inside the guarded block,
|
|
724
|
+
or narrow the docstring to the failures the guard actually catches.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
content: The source text to inspect.
|
|
728
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
One issue per function whose malformed-payload claim outruns its guard,
|
|
732
|
+
capped at the module limit.
|
|
733
|
+
"""
|
|
734
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
735
|
+
return []
|
|
736
|
+
try:
|
|
737
|
+
parsed_tree = ast.parse(content)
|
|
738
|
+
except SyntaxError:
|
|
739
|
+
return []
|
|
740
|
+
issues: list[str] = []
|
|
741
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
742
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
743
|
+
continue
|
|
744
|
+
if _function_has_exempt_decorator(each_node):
|
|
745
|
+
continue
|
|
746
|
+
docstring_text = _function_docstring_text(each_node)
|
|
747
|
+
if not docstring_text:
|
|
748
|
+
continue
|
|
749
|
+
matched_phrase = _docstring_claims_malformed_payload_is_guarded(docstring_text)
|
|
750
|
+
if not matched_phrase:
|
|
751
|
+
continue
|
|
752
|
+
if not _function_has_unguarded_payload_dereference(each_node):
|
|
753
|
+
continue
|
|
754
|
+
issues.append(
|
|
755
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring claims "
|
|
756
|
+
f"'{matched_phrase}' but a payload subscript sits outside the try/except that "
|
|
757
|
+
"returns None — a malformed-but-present payload raises rather than resolving to "
|
|
758
|
+
"None; move the dereference inside the guard or narrow the docstring "
|
|
759
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
760
|
+
)
|
|
761
|
+
if len(issues) >= MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES:
|
|
762
|
+
break
|
|
763
|
+
return issues[:MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
|
|
767
|
+
collapsed_docstring = " ".join(module_docstring.lower().split())
|
|
768
|
+
for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
|
|
769
|
+
if each_phrase in collapsed_docstring:
|
|
770
|
+
return each_phrase
|
|
771
|
+
return ""
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def check_docstring_no_inline_literal_claim(content: str, file_path: str) -> list[str]:
|
|
775
|
+
"""Flag a module docstring that asserts no literals appear inline elsewhere.
|
|
776
|
+
|
|
777
|
+
A constants-module docstring claiming "no literals appear inline in the
|
|
778
|
+
dispatcher" makes an unverifiable completeness claim about a companion file.
|
|
779
|
+
The claim drifts the moment a literal lands inline in that companion — a deny
|
|
780
|
+
or block reason left inline in the dispatcher contradicts the docstring even
|
|
781
|
+
though the constants file under edit never changed. This is the deterministic
|
|
782
|
+
slice of Category O6 (docstring prose vs implementation drift) and a
|
|
783
|
+
no-transitional-language violation in its own right: a docstring describes
|
|
784
|
+
what the module holds, not the absence of literals in a sibling. Rephrase to
|
|
785
|
+
state what the module centralizes, or drop the no-inline-literal sentence.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
content: The source text to inspect.
|
|
789
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
One issue when the module docstring carries a no-inline-literal claim,
|
|
793
|
+
capped at the module limit.
|
|
794
|
+
"""
|
|
795
|
+
if is_strict_test_file(file_path):
|
|
796
|
+
return []
|
|
797
|
+
try:
|
|
798
|
+
parsed_tree = ast.parse(content)
|
|
799
|
+
except SyntaxError:
|
|
800
|
+
return []
|
|
801
|
+
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
802
|
+
matched_phrase = _module_docstring_claims_no_inline_literal(module_docstring)
|
|
803
|
+
if not matched_phrase:
|
|
804
|
+
return []
|
|
805
|
+
issues = [
|
|
806
|
+
f"Line 1: module docstring claims '{matched_phrase}' about a companion file "
|
|
807
|
+
"— an unverifiable completeness claim that drifts the moment a literal lands "
|
|
808
|
+
"inline; state what the module centralizes instead (Category O6 docstring-vs-"
|
|
809
|
+
"implementation drift)"
|
|
810
|
+
]
|
|
811
|
+
return issues[:MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES]
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _module_docstring_summary_is_single_paragraph(module_docstring: str) -> bool:
|
|
815
|
+
stripped_text = module_docstring.strip()
|
|
816
|
+
if not stripped_text:
|
|
817
|
+
return False
|
|
818
|
+
return "\n" not in stripped_text
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _module_public_check_names(parsed_tree: ast.Module) -> list[str]:
|
|
822
|
+
deduplicated_names: dict[str, None] = {}
|
|
823
|
+
for each_statement in parsed_tree.body:
|
|
824
|
+
if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
825
|
+
continue
|
|
826
|
+
if not each_statement.name.startswith("check_"):
|
|
827
|
+
continue
|
|
828
|
+
if _function_is_private_or_dunder(each_statement.name):
|
|
829
|
+
continue
|
|
830
|
+
deduplicated_names[each_statement.name] = None
|
|
831
|
+
return list(deduplicated_names)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _distinctive_name_tokens(check_name: str) -> list[str]:
|
|
835
|
+
return [
|
|
836
|
+
each_token
|
|
837
|
+
for each_token in _name_tokens(check_name)
|
|
838
|
+
if each_token.lower() not in ALL_GENERIC_CHECK_NAME_TOKENS
|
|
839
|
+
]
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _docstring_mentions_check(docstring_text: str, check_name: str) -> bool:
|
|
843
|
+
lowered_docstring = docstring_text.lower()
|
|
844
|
+
if check_name.lower() in lowered_docstring:
|
|
845
|
+
return True
|
|
846
|
+
distinctive_tokens = _distinctive_name_tokens(check_name)
|
|
847
|
+
if not distinctive_tokens:
|
|
848
|
+
return True
|
|
849
|
+
return any(each_token.lower() in lowered_docstring for each_token in distinctive_tokens)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def check_module_docstring_names_public_checks(content: str, file_path: str) -> list[str]:
|
|
853
|
+
"""Flag a one-line module docstring that omits a public ``check_*`` function.
|
|
854
|
+
|
|
855
|
+
A check-registry module whose docstring is a single summary paragraph names
|
|
856
|
+
each check it dispatches, so a reader trusts that one line to be the full
|
|
857
|
+
roster. When the module grows a public ``check_*`` entry point the summary
|
|
858
|
+
never names, the enumeration under-describes the module — the
|
|
859
|
+
docstring-prose-vs-implementation drift the repo flags as Category O6/O8.
|
|
860
|
+
A check counts as named when the full ``check_*`` name, or any distinctive
|
|
861
|
+
(non-generic) underscore-separated token of it, appears in the summary;
|
|
862
|
+
generic tokens (``check``, ``test``, ``tests``) do not count. A module with
|
|
863
|
+
two or more public checks and any check the summary never names is reported
|
|
864
|
+
so the summary names the full roster. Modules with a multi-paragraph
|
|
865
|
+
docstring body are left to the audit lane, since their prose can carry the
|
|
866
|
+
roster without naming each check by name. This check covers hook
|
|
867
|
+
infrastructure, where the affected check registries live.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
content: The source text to inspect.
|
|
871
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
872
|
+
|
|
873
|
+
Returns:
|
|
874
|
+
One issue per public check the single-paragraph module docstring omits,
|
|
875
|
+
capped at the module limit.
|
|
876
|
+
"""
|
|
877
|
+
if is_strict_test_file(file_path):
|
|
878
|
+
return []
|
|
879
|
+
try:
|
|
880
|
+
parsed_tree = ast.parse(content)
|
|
881
|
+
except SyntaxError:
|
|
882
|
+
return []
|
|
883
|
+
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
884
|
+
if not _module_docstring_summary_is_single_paragraph(module_docstring):
|
|
885
|
+
return []
|
|
886
|
+
public_check_names = _module_public_check_names(parsed_tree)
|
|
887
|
+
if len(public_check_names) < MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER:
|
|
888
|
+
return []
|
|
889
|
+
issues: list[str] = []
|
|
890
|
+
for each_name in public_check_names:
|
|
891
|
+
if _docstring_mentions_check(module_docstring, each_name):
|
|
892
|
+
continue
|
|
893
|
+
issues.append(
|
|
894
|
+
f"Line 1: module docstring omits public check {each_name}() — name every "
|
|
895
|
+
"public check_* function the module exposes so the roster is complete "
|
|
896
|
+
"(Category O6/O8 docstring-vs-implementation drift)"
|
|
897
|
+
)
|
|
898
|
+
if len(issues) >= MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES:
|
|
899
|
+
break
|
|
900
|
+
return issues[:MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES]
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _module_string_tuple_members(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
|
|
904
|
+
members_by_constant: dict[str, frozenset[str]] = {}
|
|
905
|
+
for each_statement in parsed_tree.body:
|
|
906
|
+
if not isinstance(each_statement, ast.Assign):
|
|
907
|
+
continue
|
|
908
|
+
if not isinstance(each_statement.value, ast.Tuple):
|
|
909
|
+
continue
|
|
910
|
+
literal_members: set[str] = set()
|
|
911
|
+
every_member_is_identifier_shaped = True
|
|
912
|
+
for each_element in each_statement.value.elts:
|
|
913
|
+
if (
|
|
914
|
+
isinstance(each_element, ast.Constant)
|
|
915
|
+
and isinstance(each_element.value, str)
|
|
916
|
+
and IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN.match(each_element.value)
|
|
917
|
+
):
|
|
918
|
+
literal_members.add(each_element.value.lstrip("."))
|
|
919
|
+
continue
|
|
920
|
+
every_member_is_identifier_shaped = False
|
|
921
|
+
break
|
|
922
|
+
if not every_member_is_identifier_shaped:
|
|
923
|
+
continue
|
|
924
|
+
if len(literal_members) < MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION:
|
|
925
|
+
continue
|
|
926
|
+
for each_target in each_statement.targets:
|
|
927
|
+
if isinstance(each_target, ast.Name):
|
|
928
|
+
members_by_constant[each_target.id] = frozenset(literal_members)
|
|
929
|
+
return members_by_constant
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _names_referenced_in_function(
|
|
933
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
934
|
+
) -> set[str]:
|
|
935
|
+
return {
|
|
936
|
+
each_node.id
|
|
937
|
+
for each_node in ast.walk(function_node)
|
|
938
|
+
if isinstance(each_node, ast.Name)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _docstring_inline_code_tokens(docstring_text: str) -> set[str]:
|
|
943
|
+
tokens: set[str] = set()
|
|
944
|
+
for each_match in INLINE_CODE_TOKEN_PATTERN.finditer(docstring_text):
|
|
945
|
+
token = each_match.group(1).strip().lstrip(".")
|
|
946
|
+
if token:
|
|
947
|
+
tokens.add(token)
|
|
948
|
+
return tokens
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> list[str]:
|
|
952
|
+
"""Flag a docstring enumeration that drifts from a literal tuple it reads.
|
|
953
|
+
|
|
954
|
+
The drift this catches: a function reads a module-level tuple of literal
|
|
955
|
+
string members and its docstring enumerates inline-code tokens that name
|
|
956
|
+
some of those members, but the enumerated set and the tuple membership
|
|
957
|
+
differ. A token the docstring lists that the tuple lacks, or a tuple member
|
|
958
|
+
the docstring omits, misleads a reader who trusts the prose enumeration to
|
|
959
|
+
match the detection set — the deterministic slice of Category O6
|
|
960
|
+
docstring-prose-vs-implementation drift. The check binds only when the
|
|
961
|
+
docstring's inline-code tokens overlap the tuple membership, so a docstring
|
|
962
|
+
that names unrelated attributes is left alone. This check covers hook
|
|
963
|
+
infrastructure, where the affected detection tuples live.
|
|
964
|
+
|
|
965
|
+
Args:
|
|
966
|
+
content: The source text to inspect.
|
|
967
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
968
|
+
|
|
969
|
+
Returns:
|
|
970
|
+
One issue per function whose docstring enumeration diverges from the
|
|
971
|
+
tuple it reads, capped at the module limit.
|
|
972
|
+
"""
|
|
973
|
+
if is_strict_test_file(file_path):
|
|
974
|
+
return []
|
|
975
|
+
try:
|
|
976
|
+
parsed_tree = ast.parse(content)
|
|
977
|
+
except SyntaxError:
|
|
978
|
+
return []
|
|
979
|
+
members_by_constant = _module_string_tuple_members(parsed_tree)
|
|
980
|
+
if not members_by_constant:
|
|
981
|
+
return []
|
|
982
|
+
issues: list[str] = []
|
|
983
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
984
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
985
|
+
continue
|
|
986
|
+
docstring_text = _function_docstring_text(each_node)
|
|
987
|
+
if not docstring_text:
|
|
988
|
+
continue
|
|
989
|
+
docstring_tokens = _docstring_inline_code_tokens(docstring_text)
|
|
990
|
+
if not docstring_tokens:
|
|
991
|
+
continue
|
|
992
|
+
referenced_names = _names_referenced_in_function(each_node)
|
|
993
|
+
for each_constant_name in referenced_names & set(members_by_constant):
|
|
994
|
+
tuple_members = members_by_constant[each_constant_name]
|
|
995
|
+
if not (docstring_tokens & tuple_members):
|
|
996
|
+
continue
|
|
997
|
+
if docstring_tokens == tuple_members:
|
|
998
|
+
continue
|
|
999
|
+
docstring_only = sorted(docstring_tokens - tuple_members)
|
|
1000
|
+
tuple_only = sorted(tuple_members - docstring_tokens)
|
|
1001
|
+
issues.append(
|
|
1002
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring enumerates "
|
|
1003
|
+
f"{sorted(docstring_tokens)} but {each_constant_name} holds "
|
|
1004
|
+
f"{sorted(tuple_members)} — docstring-only: {docstring_only}, "
|
|
1005
|
+
f"tuple-only: {tuple_only}; match the enumeration to the tuple "
|
|
1006
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
1007
|
+
)
|
|
1008
|
+
if len(issues) >= MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES:
|
|
1009
|
+
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
1010
|
+
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _returns_section_text(docstring_text: str) -> str:
|
|
1014
|
+
docstring_lines = docstring_text.splitlines()
|
|
1015
|
+
returns_section_lines: list[str] = []
|
|
1016
|
+
inside_returns_section = False
|
|
1017
|
+
for each_line in docstring_lines:
|
|
1018
|
+
stripped_line = each_line.strip()
|
|
1019
|
+
if stripped_line in ("Returns:", "Yields:"):
|
|
1020
|
+
inside_returns_section = True
|
|
1021
|
+
continue
|
|
1022
|
+
if not inside_returns_section:
|
|
1023
|
+
continue
|
|
1024
|
+
if _is_docstring_terminating_section_header(stripped_line):
|
|
1025
|
+
break
|
|
1026
|
+
returns_section_lines.append(stripped_line)
|
|
1027
|
+
return " ".join(returns_section_lines)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _plural_families_in_returns_section(returns_section_text: str) -> set[str]:
|
|
1031
|
+
return {
|
|
1032
|
+
each_match.group(1)
|
|
1033
|
+
for each_match in DOCSTRING_PLURAL_FAMILY_STOP_PATTERN.finditer(returns_section_text)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _returned_dict_key_names(
|
|
1038
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1039
|
+
) -> set[str]:
|
|
1040
|
+
all_key_names: set[str] = set()
|
|
1041
|
+
for each_node in ast.walk(function_node):
|
|
1042
|
+
if not isinstance(each_node, ast.Return):
|
|
1043
|
+
continue
|
|
1044
|
+
if not isinstance(each_node.value, ast.Dict):
|
|
1045
|
+
continue
|
|
1046
|
+
for each_key in each_node.value.keys:
|
|
1047
|
+
if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
|
|
1048
|
+
all_key_names.add(each_key.value)
|
|
1049
|
+
return all_key_names
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _family_prefixed_key_count(family: str, all_key_names: set[str]) -> int:
|
|
1053
|
+
family_prefix = f"{family}_"
|
|
1054
|
+
return sum(1 for each_key in all_key_names if each_key.startswith(family_prefix))
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def check_docstring_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
|
|
1058
|
+
"""Flag a Returns clause plural noun that names one dict key in its family.
|
|
1059
|
+
|
|
1060
|
+
The drift this catches: a function returns a dict literal whose keys carry
|
|
1061
|
+
prefix families (``sheen_mid``, ``body_highlight``), and its Returns clause
|
|
1062
|
+
names one family with a plural noun (``the sheen stops``) while exactly one
|
|
1063
|
+
key in that family exists. The plural prose claims two or more entries the
|
|
1064
|
+
dict no longer holds — the shape that appears when a producer removes the
|
|
1065
|
+
second key in a family but leaves the plural prose untouched. The check binds
|
|
1066
|
+
only when the plural family prefixes exactly one returned dict key, so a
|
|
1067
|
+
singular noun, a family with two or more keys, and a family absent from the
|
|
1068
|
+
dict are all left alone. This is the deterministic single-key slice of
|
|
1069
|
+
Category O6 docstring-prose-vs-implementation drift.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
content: The source text to inspect.
|
|
1073
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1074
|
+
|
|
1075
|
+
Returns:
|
|
1076
|
+
One issue per function whose Returns clause names a plural family that
|
|
1077
|
+
prefixes a single returned dict key, capped at the module limit.
|
|
1078
|
+
"""
|
|
1079
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1080
|
+
return []
|
|
1081
|
+
try:
|
|
1082
|
+
parsed_tree = ast.parse(content)
|
|
1083
|
+
except SyntaxError:
|
|
1084
|
+
return []
|
|
1085
|
+
issues: list[str] = []
|
|
1086
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1087
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1088
|
+
continue
|
|
1089
|
+
docstring_text = _function_docstring_text(each_node)
|
|
1090
|
+
if not docstring_text:
|
|
1091
|
+
continue
|
|
1092
|
+
returns_section_text = _returns_section_text(docstring_text)
|
|
1093
|
+
if not returns_section_text:
|
|
1094
|
+
continue
|
|
1095
|
+
plural_families = _plural_families_in_returns_section(returns_section_text)
|
|
1096
|
+
if not plural_families:
|
|
1097
|
+
continue
|
|
1098
|
+
all_key_names = _returned_dict_key_names(each_node)
|
|
1099
|
+
for each_family in sorted(plural_families):
|
|
1100
|
+
matching_key_count = _family_prefixed_key_count(each_family, all_key_names)
|
|
1101
|
+
if matching_key_count != SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT:
|
|
1102
|
+
continue
|
|
1103
|
+
issues.append(
|
|
1104
|
+
f"Line {each_node.lineno}: {each_node.name}() Returns clause says "
|
|
1105
|
+
f"'the {each_family} stops' (plural) but the returned dict holds a "
|
|
1106
|
+
f"single {each_family}_ key — match the noun to the cardinality "
|
|
1107
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
1108
|
+
)
|
|
1109
|
+
if len(issues) >= MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES:
|
|
1110
|
+
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1111
|
+
return issues[:MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES]
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _called_callee_name(statement: ast.stmt) -> str:
|
|
1115
|
+
candidate_expression: ast.expr | None = None
|
|
1116
|
+
if isinstance(statement, ast.Expr):
|
|
1117
|
+
candidate_expression = statement.value
|
|
1118
|
+
elif isinstance(statement, ast.Assign):
|
|
1119
|
+
candidate_expression = statement.value
|
|
1120
|
+
elif isinstance(statement, ast.AnnAssign):
|
|
1121
|
+
candidate_expression = statement.value
|
|
1122
|
+
if candidate_expression is None:
|
|
1123
|
+
return ""
|
|
1124
|
+
if isinstance(candidate_expression, ast.Await):
|
|
1125
|
+
candidate_expression = candidate_expression.value
|
|
1126
|
+
if not isinstance(candidate_expression, ast.Call):
|
|
1127
|
+
return ""
|
|
1128
|
+
return _call_callee_name(candidate_expression)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _called_callees_in_expression(expression: ast.expr) -> set[str]:
|
|
1132
|
+
callees: set[str] = set()
|
|
1133
|
+
for each_descendant in ast.walk(expression):
|
|
1134
|
+
if not isinstance(each_descendant, ast.Call):
|
|
1135
|
+
continue
|
|
1136
|
+
callee_name = _call_callee_name(each_descendant)
|
|
1137
|
+
if callee_name:
|
|
1138
|
+
callees.add(callee_name)
|
|
1139
|
+
return callees
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _linear_step_callees(
|
|
1143
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1144
|
+
) -> set[str]:
|
|
1145
|
+
callees: set[str] = set()
|
|
1146
|
+
for each_statement in function_node.body:
|
|
1147
|
+
callee_name = _called_callee_name(each_statement)
|
|
1148
|
+
if callee_name:
|
|
1149
|
+
callees.add(callee_name)
|
|
1150
|
+
if isinstance(each_statement, ast.If):
|
|
1151
|
+
callees |= _called_callees_in_expression(each_statement.test)
|
|
1152
|
+
return callees
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _branch_guarded_dispatch_callees(
|
|
1156
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1157
|
+
) -> set[str]:
|
|
1158
|
+
callees: set[str] = set()
|
|
1159
|
+
for each_statement in function_node.body:
|
|
1160
|
+
if not isinstance(each_statement, ast.If):
|
|
1161
|
+
continue
|
|
1162
|
+
for each_branch_statement in each_statement.body + each_statement.orelse:
|
|
1163
|
+
for each_descendant in ast.walk(each_branch_statement):
|
|
1164
|
+
if not isinstance(each_descendant, ast.If):
|
|
1165
|
+
continue
|
|
1166
|
+
callees |= _called_callees_in_expression(each_descendant.test)
|
|
1167
|
+
return callees
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _bare_callee_name(qualified_callee: str) -> str:
|
|
1171
|
+
return qualified_callee.rsplit(".", 1)[-1]
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _docstring_names_all_callee_tokens(docstring_text: str, callee_name: str) -> bool:
|
|
1175
|
+
bare_name = _bare_callee_name(callee_name)
|
|
1176
|
+
lowered_docstring = docstring_text.lower()
|
|
1177
|
+
if bare_name.lower() in lowered_docstring:
|
|
1178
|
+
return True
|
|
1179
|
+
callee_tokens = _name_tokens(bare_name)
|
|
1180
|
+
if not callee_tokens:
|
|
1181
|
+
return False
|
|
1182
|
+
return all(each_token.lower() in lowered_docstring for each_token in callee_tokens)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def check_docstring_step_enumeration_dispatch_coverage(
|
|
1186
|
+
content: str, file_path: str
|
|
1187
|
+
) -> list[str]:
|
|
1188
|
+
"""Flag a step-enumeration docstring that omits a conditional dispatch call.
|
|
1189
|
+
|
|
1190
|
+
The drift this catches: a function whose docstring enumerates a linear
|
|
1191
|
+
sequence of steps (``Navigates ..., searches ..., clicks ..., uploads ...``)
|
|
1192
|
+
matching the body's linear-step calls, while the body also routes to a
|
|
1193
|
+
corrective workflow step inside an ``if``/``elif`` branch — a cancel-and-reinitiate
|
|
1194
|
+
or replace-target-row step — whose name the prose never spells out. A reader
|
|
1195
|
+
who trusts the step list to be complete misses that the function can take that
|
|
1196
|
+
conditional path. This is the deterministic slice of Category O4 (step-ordering
|
|
1197
|
+
narrative): a body that guards a branch-only workflow step the enumeration omits.
|
|
1198
|
+
|
|
1199
|
+
A linear-step call is one made as a top-level statement or inside the ``If.test``
|
|
1200
|
+
guard of a top-level ``if`` (``if not await self.navigate(): return``). A
|
|
1201
|
+
dispatch step is a call inside a guard (``If.test``) nested within an
|
|
1202
|
+
``if``/``elif`` branch (``if not await cancel_and_reinitiate_update(...): return``)
|
|
1203
|
+
that is never also a linear step — the same control-flow-gating shape as a
|
|
1204
|
+
linear step, so plain (unguarded) logging, screenshot, or method-on-local
|
|
1205
|
+
calls inside a branch body are not dispatch steps. The check binds only when
|
|
1206
|
+
the docstring already names two or more linear-step callees by their
|
|
1207
|
+
underscore tokens, proving the prose is a step enumeration describing this
|
|
1208
|
+
body. A dispatch-step callee with two or more underscore tokens, none of
|
|
1209
|
+
whose tokens appear in the prose, is flagged.
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
content: The source text to inspect.
|
|
1213
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
One issue per conditional dispatch call the step enumeration omits, capped
|
|
1217
|
+
at the module limit.
|
|
1218
|
+
"""
|
|
1219
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1220
|
+
return []
|
|
1221
|
+
try:
|
|
1222
|
+
parsed_tree = ast.parse(content)
|
|
1223
|
+
except SyntaxError:
|
|
1224
|
+
return []
|
|
1225
|
+
issues: list[str] = []
|
|
1226
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1227
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1228
|
+
continue
|
|
1229
|
+
if _function_has_exempt_decorator(each_node):
|
|
1230
|
+
continue
|
|
1231
|
+
docstring_text = _function_docstring_text(each_node)
|
|
1232
|
+
if not docstring_text:
|
|
1233
|
+
continue
|
|
1234
|
+
linear_step_callees = _linear_step_callees(each_node)
|
|
1235
|
+
named_linear_steps = [
|
|
1236
|
+
each_callee
|
|
1237
|
+
for each_callee in linear_step_callees
|
|
1238
|
+
if _docstring_names_all_callee_tokens(docstring_text, each_callee)
|
|
1239
|
+
]
|
|
1240
|
+
if len(named_linear_steps) < MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK:
|
|
1241
|
+
continue
|
|
1242
|
+
branch_only_callees = (
|
|
1243
|
+
_branch_guarded_dispatch_callees(each_node) - linear_step_callees
|
|
1244
|
+
)
|
|
1245
|
+
for each_callee in sorted(branch_only_callees):
|
|
1246
|
+
if len(_name_tokens(_bare_callee_name(each_callee))) < MINIMUM_TOKENS_FOR_DISPATCH_CALLEE:
|
|
1247
|
+
continue
|
|
1248
|
+
if _docstring_names_all_callee_tokens(docstring_text, each_callee):
|
|
1249
|
+
continue
|
|
1250
|
+
issues.append(
|
|
1251
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring enumerates linear "
|
|
1252
|
+
f"steps but omits the conditional dispatch step {each_callee}() the body "
|
|
1253
|
+
"guards inside a branch — add the corrective-path step to the enumeration "
|
|
1254
|
+
"(Category O4 step-ordering narrative drift)"
|
|
1255
|
+
)
|
|
1256
|
+
if len(issues) >= MAX_DOCSTRING_STEP_DISPATCH_ISSUES:
|
|
1257
|
+
return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
|
|
1258
|
+
return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _imported_binding_names(import_node: ast.Import | ast.ImportFrom) -> set[str]:
|
|
1262
|
+
bound_names: set[str] = set()
|
|
1263
|
+
for each_alias in import_node.names:
|
|
1264
|
+
bound_names.add(each_alias.asname or each_alias.name.split(".", 1)[0])
|
|
1265
|
+
return bound_names
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _module_defined_and_imported_names(parsed_tree: ast.Module) -> set[str]:
|
|
1269
|
+
defined_names: set[str] = set()
|
|
1270
|
+
for each_node in ast.walk(parsed_tree):
|
|
1271
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
1272
|
+
defined_names |= _imported_binding_names(each_node)
|
|
1273
|
+
elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
1274
|
+
defined_names.add(each_node.name)
|
|
1275
|
+
elif isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Store):
|
|
1276
|
+
defined_names.add(each_node.id)
|
|
1277
|
+
return defined_names
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _module_attribute_access_names(parsed_tree: ast.Module) -> set[str]:
|
|
1281
|
+
attribute_names: set[str] = set()
|
|
1282
|
+
for each_node in ast.walk(parsed_tree):
|
|
1283
|
+
if isinstance(each_node, ast.Attribute):
|
|
1284
|
+
attribute_names.add(each_node.attr)
|
|
1285
|
+
return attribute_names
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _docstring_constant_node_ids(parsed_tree: ast.Module) -> set[int]:
|
|
1289
|
+
docstring_node_ids: set[int] = set()
|
|
1290
|
+
for each_node in ast.walk(parsed_tree):
|
|
1291
|
+
if not isinstance(
|
|
1292
|
+
each_node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1293
|
+
):
|
|
1294
|
+
continue
|
|
1295
|
+
body_statements = each_node.body
|
|
1296
|
+
if not body_statements or not _statement_is_docstring(body_statements[0]):
|
|
1297
|
+
continue
|
|
1298
|
+
first_statement = body_statements[0]
|
|
1299
|
+
assert isinstance(first_statement, ast.Expr)
|
|
1300
|
+
docstring_node_ids.add(id(first_statement.value))
|
|
1301
|
+
return docstring_node_ids
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _module_string_literal_word_runs(parsed_tree: ast.Module) -> set[str]:
|
|
1305
|
+
docstring_node_ids = _docstring_constant_node_ids(parsed_tree)
|
|
1306
|
+
word_runs: set[str] = set()
|
|
1307
|
+
for each_node in ast.walk(parsed_tree):
|
|
1308
|
+
if not (isinstance(each_node, ast.Constant) and isinstance(each_node.value, str)):
|
|
1309
|
+
continue
|
|
1310
|
+
if id(each_node) in docstring_node_ids:
|
|
1311
|
+
continue
|
|
1312
|
+
for each_run in re.findall(r"[A-Za-z0-9_]+", each_node.value):
|
|
1313
|
+
if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_run):
|
|
1314
|
+
word_runs.add(each_run)
|
|
1315
|
+
return word_runs
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _name_word_prefix_families(all_supporting_names: set[str]) -> set[str]:
|
|
1319
|
+
prefix_families: set[str] = set()
|
|
1320
|
+
for each_name in all_supporting_names:
|
|
1321
|
+
leading_word = each_name.split("_", 1)[0]
|
|
1322
|
+
prefix_families.add(leading_word)
|
|
1323
|
+
return prefix_families
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _token_is_word_run_of_any_name(token: str, all_supporting_names: set[str]) -> bool:
|
|
1327
|
+
return any(f"_{token}_" in f"_{each_name}_" for each_name in all_supporting_names)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _docstring_words(docstring_text: str) -> list[str]:
|
|
1331
|
+
return [
|
|
1332
|
+
each_word.strip(".,:;()[]{}'\"`")
|
|
1333
|
+
for each_word in docstring_text.replace("`", " ").split()
|
|
1334
|
+
]
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _docstring_frames_token_as_non_constant_reference(
|
|
1338
|
+
token: str, docstring_text: str
|
|
1339
|
+
) -> bool:
|
|
1340
|
+
if any(
|
|
1341
|
+
f"{token}{each_suffix}" in docstring_text
|
|
1342
|
+
for each_suffix in ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES
|
|
1343
|
+
):
|
|
1344
|
+
return True
|
|
1345
|
+
words = _docstring_words(docstring_text)
|
|
1346
|
+
for each_index, each_word in enumerate(words):
|
|
1347
|
+
if each_word != token:
|
|
1348
|
+
continue
|
|
1349
|
+
neighbors = words[max(each_index - DOCSTRING_REFERENCE_MARKER_WINDOW, 0) : each_index + DOCSTRING_REFERENCE_MARKER_WINDOW + 1]
|
|
1350
|
+
if any(
|
|
1351
|
+
each_neighbor.lower() in ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS
|
|
1352
|
+
for each_neighbor in neighbors
|
|
1353
|
+
):
|
|
1354
|
+
return True
|
|
1355
|
+
return False
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _docstring_constant_token_is_supported(
|
|
1359
|
+
token: str, parsed_tree: ast.Module, all_known_names: set[str], docstring_text: str
|
|
1360
|
+
) -> bool:
|
|
1361
|
+
supporting_predicates = (
|
|
1362
|
+
lambda: token in all_known_names,
|
|
1363
|
+
lambda: token in ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
|
|
1364
|
+
lambda: token in _module_attribute_access_names(parsed_tree),
|
|
1365
|
+
lambda: token in _module_string_literal_word_runs(parsed_tree),
|
|
1366
|
+
lambda: _token_is_word_run_of_any_name(token, all_known_names),
|
|
1367
|
+
lambda: _docstring_frames_token_as_non_constant_reference(token, docstring_text),
|
|
1368
|
+
lambda: token.split("_", 1)[0] in _name_word_prefix_families(all_known_names),
|
|
1369
|
+
)
|
|
1370
|
+
return any(each_predicate() for each_predicate in supporting_predicates)
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _docstring_constant_tokens(docstring_text: str) -> set[str]:
|
|
1374
|
+
candidate_tokens: set[str] = set()
|
|
1375
|
+
for each_word in docstring_text.replace("`", " ").split():
|
|
1376
|
+
stripped_word = each_word.strip(".,:;()[]{}'\"")
|
|
1377
|
+
if stripped_word.startswith("__") and stripped_word.endswith("__"):
|
|
1378
|
+
continue
|
|
1379
|
+
if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(stripped_word):
|
|
1380
|
+
candidate_tokens.add(stripped_word)
|
|
1381
|
+
return candidate_tokens
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _documentable_nodes_with_docstrings(
|
|
1385
|
+
parsed_tree: ast.Module,
|
|
1386
|
+
) -> list[tuple[int, str]]:
|
|
1387
|
+
documentable: list[tuple[int, str]] = []
|
|
1388
|
+
module_docstring = ast.get_docstring(parsed_tree)
|
|
1389
|
+
if module_docstring:
|
|
1390
|
+
documentable.append((1, module_docstring))
|
|
1391
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1392
|
+
if not isinstance(
|
|
1393
|
+
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1394
|
+
):
|
|
1395
|
+
continue
|
|
1396
|
+
node_docstring = ast.get_docstring(each_node)
|
|
1397
|
+
if node_docstring:
|
|
1398
|
+
documentable.append((each_node.lineno, node_docstring))
|
|
1399
|
+
return documentable
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def check_docstring_names_undefined_constant(content: str, file_path: str) -> list[str]:
|
|
1403
|
+
"""Flag a docstring naming an UPPER_SNAKE constant nothing in the module backs.
|
|
1404
|
+
|
|
1405
|
+
The drift this catches: a docstring names an all-caps, underscore-joined
|
|
1406
|
+
token as a contract identifier (``NATIVE_EVALUATE_FUNCTION_NAME``) while the
|
|
1407
|
+
enclosing module carries no supporting reference for it. A reader who trusts
|
|
1408
|
+
the docstring to name a real symbol finds nothing — the deterministic slice
|
|
1409
|
+
of Category O6 docstring-prose-vs-implementation drift where the named token
|
|
1410
|
+
is structurally a constant and unresolvable against the module.
|
|
1411
|
+
|
|
1412
|
+
A token counts as supported, and is left alone, when any of these holds: it
|
|
1413
|
+
is defined at module scope or imported; it is a naming-convention descriptor
|
|
1414
|
+
(``UPPER_SNAKE_CASE`` and its siblings, describing a style, not a symbol); it
|
|
1415
|
+
is the attribute of an attribute access in the body (``os.O_NOFOLLOW``,
|
|
1416
|
+
``config.timing.MAX_DELAY``, resolving stdlib and dotted-import constants);
|
|
1417
|
+
it is an all-caps word run inside a string literal (an env-var key read via
|
|
1418
|
+
``os.environ[...]`` or ``os.getenv(...)``, an API enum string value, a doc
|
|
1419
|
+
stem in ``CODE_RULES.md``); it is a contiguous word run of a defined or
|
|
1420
|
+
imported name (``GH_TOKEN`` within ``ALL_GH_TOKEN_ENV_VAR_NAMES``); it shares
|
|
1421
|
+
a leading word component with a defined or imported name, marking the same
|
|
1422
|
+
enum family (``MODE_CLASSIFY`` beside an imported ``MODE_STRICT``); or the
|
|
1423
|
+
docstring prose frames it as a non-constant reference — followed by a file
|
|
1424
|
+
suffix (``CODE_RULES.md``) or sitting within two words of a marker such as
|
|
1425
|
+
``rule``, ``doc``, ``file``, ``env``, ``variable``, ``set``, ``read``,
|
|
1426
|
+
``per``, ``follows``, or ``see`` (``per CODE_RULES``, ``LLM_SETTINGS_ROOT is
|
|
1427
|
+
set to``). Single-segment all-caps acronyms (``HTTP``, ``JSON``) and dunder
|
|
1428
|
+
names (``__all__``) are not constants and are left alone.
|
|
1429
|
+
|
|
1430
|
+
Args:
|
|
1431
|
+
content: The source text to inspect.
|
|
1432
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1433
|
+
|
|
1434
|
+
Returns:
|
|
1435
|
+
One issue per docstring token that no module reference backs, capped at
|
|
1436
|
+
the module limit.
|
|
1437
|
+
"""
|
|
1438
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1439
|
+
return []
|
|
1440
|
+
try:
|
|
1441
|
+
parsed_tree = ast.parse(content)
|
|
1442
|
+
except SyntaxError:
|
|
1443
|
+
return []
|
|
1444
|
+
known_names = _module_defined_and_imported_names(parsed_tree)
|
|
1445
|
+
issues: list[str] = []
|
|
1446
|
+
for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
|
|
1447
|
+
for each_token in sorted(_docstring_constant_tokens(each_docstring)):
|
|
1448
|
+
if _docstring_constant_token_is_supported(
|
|
1449
|
+
each_token, parsed_tree, known_names, each_docstring
|
|
1450
|
+
):
|
|
1451
|
+
continue
|
|
1452
|
+
issues.append(
|
|
1453
|
+
f"Line {each_line_number}: docstring names '{each_token}' which the "
|
|
1454
|
+
"module neither defines at module scope nor imports — name the real "
|
|
1455
|
+
"symbol or drop the reference (Category O6 docstring-vs-implementation "
|
|
1456
|
+
"drift)"
|
|
1457
|
+
)
|
|
1458
|
+
if len(issues) >= MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES:
|
|
1459
|
+
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|
|
1460
|
+
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|