claude-dev-env 1.72.0 → 1.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/audit-rubrics/category_rubrics/category-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 +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +616 -0
- package/hooks/blocking/code_rules_enforcer.py +22 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- 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_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_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -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_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +61 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -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/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -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/docstring-prose-matches-implementation.md +2 -1
- package/skills/autoconverge/SKILL.md +93 -0
- package/skills/autoconverge/workflow/converge.mjs +27 -2
- 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,48 @@ 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,
|
|
25
28
|
ALL_DOCSTRING_IMPLICIT_INSTANCE_PARAMETER_NAMES,
|
|
26
29
|
ALL_DOCSTRING_MULTIPLE_CONDITION_JOINING_PHRASES,
|
|
27
30
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES,
|
|
31
|
+
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES,
|
|
32
|
+
ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS,
|
|
33
|
+
ALL_GENERIC_CHECK_NAME_TOKENS,
|
|
34
|
+
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
|
|
28
35
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
|
|
36
|
+
DOCSTRING_REFERENCE_MARKER_WINDOW,
|
|
29
37
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
|
|
30
38
|
MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
|
|
31
39
|
MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
|
|
32
40
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
|
|
33
41
|
MAX_DOCSTRING_FORMAT_ISSUES,
|
|
42
|
+
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
|
|
34
43
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES,
|
|
44
|
+
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
45
|
+
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
46
|
+
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
47
|
+
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES,
|
|
48
|
+
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK,
|
|
49
|
+
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
35
50
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
51
|
+
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
|
|
52
|
+
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
|
|
36
53
|
)
|
|
37
54
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
55
|
+
ALL_CAPS_WITH_UNDERSCORE_PATTERN,
|
|
38
56
|
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
39
57
|
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
40
58
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
41
59
|
DOCSTRING_ARG_ENTRY_PATTERN,
|
|
60
|
+
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
|
|
61
|
+
INLINE_CODE_TOKEN_PATTERN,
|
|
42
62
|
)
|
|
43
63
|
|
|
44
64
|
|
|
@@ -619,3 +639,599 @@ def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]
|
|
|
619
639
|
if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
|
|
620
640
|
break
|
|
621
641
|
return issues[:MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
|
|
645
|
+
collapsed_docstring = " ".join(module_docstring.lower().split())
|
|
646
|
+
for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
|
|
647
|
+
if each_phrase in collapsed_docstring:
|
|
648
|
+
return each_phrase
|
|
649
|
+
return ""
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def check_docstring_no_inline_literal_claim(content: str, file_path: str) -> list[str]:
|
|
653
|
+
"""Flag a module docstring that asserts no literals appear inline elsewhere.
|
|
654
|
+
|
|
655
|
+
A constants-module docstring claiming "no literals appear inline in the
|
|
656
|
+
dispatcher" makes an unverifiable completeness claim about a companion file.
|
|
657
|
+
The claim drifts the moment a literal lands inline in that companion — a deny
|
|
658
|
+
or block reason left inline in the dispatcher contradicts the docstring even
|
|
659
|
+
though the constants file under edit never changed. This is the deterministic
|
|
660
|
+
slice of Category O6 (docstring prose vs implementation drift) and a
|
|
661
|
+
no-transitional-language violation in its own right: a docstring describes
|
|
662
|
+
what the module holds, not the absence of literals in a sibling. Rephrase to
|
|
663
|
+
state what the module centralizes, or drop the no-inline-literal sentence.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
content: The source text to inspect.
|
|
667
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
One issue when the module docstring carries a no-inline-literal claim,
|
|
671
|
+
capped at the module limit.
|
|
672
|
+
"""
|
|
673
|
+
if is_strict_test_file(file_path):
|
|
674
|
+
return []
|
|
675
|
+
try:
|
|
676
|
+
parsed_tree = ast.parse(content)
|
|
677
|
+
except SyntaxError:
|
|
678
|
+
return []
|
|
679
|
+
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
680
|
+
matched_phrase = _module_docstring_claims_no_inline_literal(module_docstring)
|
|
681
|
+
if not matched_phrase:
|
|
682
|
+
return []
|
|
683
|
+
issues = [
|
|
684
|
+
f"Line 1: module docstring claims '{matched_phrase}' about a companion file "
|
|
685
|
+
"— an unverifiable completeness claim that drifts the moment a literal lands "
|
|
686
|
+
"inline; state what the module centralizes instead (Category O6 docstring-vs-"
|
|
687
|
+
"implementation drift)"
|
|
688
|
+
]
|
|
689
|
+
return issues[:MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES]
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _module_docstring_summary_is_single_paragraph(module_docstring: str) -> bool:
|
|
693
|
+
stripped_text = module_docstring.strip()
|
|
694
|
+
if not stripped_text:
|
|
695
|
+
return False
|
|
696
|
+
return "\n" not in stripped_text
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _module_public_check_names(parsed_tree: ast.Module) -> list[str]:
|
|
700
|
+
deduplicated_names: dict[str, None] = {}
|
|
701
|
+
for each_statement in parsed_tree.body:
|
|
702
|
+
if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
703
|
+
continue
|
|
704
|
+
if not each_statement.name.startswith("check_"):
|
|
705
|
+
continue
|
|
706
|
+
if _function_is_private_or_dunder(each_statement.name):
|
|
707
|
+
continue
|
|
708
|
+
deduplicated_names[each_statement.name] = None
|
|
709
|
+
return list(deduplicated_names)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _distinctive_name_tokens(check_name: str) -> list[str]:
|
|
713
|
+
return [
|
|
714
|
+
each_token
|
|
715
|
+
for each_token in _name_tokens(check_name)
|
|
716
|
+
if each_token.lower() not in ALL_GENERIC_CHECK_NAME_TOKENS
|
|
717
|
+
]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _docstring_mentions_check(docstring_text: str, check_name: str) -> bool:
|
|
721
|
+
lowered_docstring = docstring_text.lower()
|
|
722
|
+
if check_name.lower() in lowered_docstring:
|
|
723
|
+
return True
|
|
724
|
+
distinctive_tokens = _distinctive_name_tokens(check_name)
|
|
725
|
+
if not distinctive_tokens:
|
|
726
|
+
return True
|
|
727
|
+
return any(each_token.lower() in lowered_docstring for each_token in distinctive_tokens)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def check_module_docstring_names_public_checks(content: str, file_path: str) -> list[str]:
|
|
731
|
+
"""Flag a one-line module docstring that omits a public ``check_*`` function.
|
|
732
|
+
|
|
733
|
+
A check-registry module whose docstring is a single summary paragraph names
|
|
734
|
+
each check it dispatches, so a reader trusts that one line to be the full
|
|
735
|
+
roster. When the module grows a public ``check_*`` entry point the summary
|
|
736
|
+
never names, the enumeration under-describes the module — the
|
|
737
|
+
docstring-prose-vs-implementation drift the repo flags as Category O6/O8.
|
|
738
|
+
A check counts as named when the full ``check_*`` name, or any distinctive
|
|
739
|
+
(non-generic) underscore-separated token of it, appears in the summary;
|
|
740
|
+
generic tokens (``check``, ``test``, ``tests``) do not count. A module with
|
|
741
|
+
two or more public checks and any check the summary never names is reported
|
|
742
|
+
so the summary names the full roster. Modules with a multi-paragraph
|
|
743
|
+
docstring body are left to the audit lane, since their prose can carry the
|
|
744
|
+
roster without naming each check by name. This check covers hook
|
|
745
|
+
infrastructure, where the affected check registries live.
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
content: The source text to inspect.
|
|
749
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
750
|
+
|
|
751
|
+
Returns:
|
|
752
|
+
One issue per public check the single-paragraph module docstring omits,
|
|
753
|
+
capped at the module limit.
|
|
754
|
+
"""
|
|
755
|
+
if is_strict_test_file(file_path):
|
|
756
|
+
return []
|
|
757
|
+
try:
|
|
758
|
+
parsed_tree = ast.parse(content)
|
|
759
|
+
except SyntaxError:
|
|
760
|
+
return []
|
|
761
|
+
module_docstring = ast.get_docstring(parsed_tree) or ""
|
|
762
|
+
if not _module_docstring_summary_is_single_paragraph(module_docstring):
|
|
763
|
+
return []
|
|
764
|
+
public_check_names = _module_public_check_names(parsed_tree)
|
|
765
|
+
if len(public_check_names) < MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER:
|
|
766
|
+
return []
|
|
767
|
+
issues: list[str] = []
|
|
768
|
+
for each_name in public_check_names:
|
|
769
|
+
if _docstring_mentions_check(module_docstring, each_name):
|
|
770
|
+
continue
|
|
771
|
+
issues.append(
|
|
772
|
+
f"Line 1: module docstring omits public check {each_name}() — name every "
|
|
773
|
+
"public check_* function the module exposes so the roster is complete "
|
|
774
|
+
"(Category O6/O8 docstring-vs-implementation drift)"
|
|
775
|
+
)
|
|
776
|
+
if len(issues) >= MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES:
|
|
777
|
+
break
|
|
778
|
+
return issues[:MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES]
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _module_string_tuple_members(parsed_tree: ast.Module) -> dict[str, frozenset[str]]:
|
|
782
|
+
members_by_constant: dict[str, frozenset[str]] = {}
|
|
783
|
+
for each_statement in parsed_tree.body:
|
|
784
|
+
if not isinstance(each_statement, ast.Assign):
|
|
785
|
+
continue
|
|
786
|
+
if not isinstance(each_statement.value, ast.Tuple):
|
|
787
|
+
continue
|
|
788
|
+
literal_members: set[str] = set()
|
|
789
|
+
every_member_is_identifier_shaped = True
|
|
790
|
+
for each_element in each_statement.value.elts:
|
|
791
|
+
if (
|
|
792
|
+
isinstance(each_element, ast.Constant)
|
|
793
|
+
and isinstance(each_element.value, str)
|
|
794
|
+
and IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN.match(each_element.value)
|
|
795
|
+
):
|
|
796
|
+
literal_members.add(each_element.value.lstrip("."))
|
|
797
|
+
continue
|
|
798
|
+
every_member_is_identifier_shaped = False
|
|
799
|
+
break
|
|
800
|
+
if not every_member_is_identifier_shaped:
|
|
801
|
+
continue
|
|
802
|
+
if len(literal_members) < MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION:
|
|
803
|
+
continue
|
|
804
|
+
for each_target in each_statement.targets:
|
|
805
|
+
if isinstance(each_target, ast.Name):
|
|
806
|
+
members_by_constant[each_target.id] = frozenset(literal_members)
|
|
807
|
+
return members_by_constant
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _names_referenced_in_function(
|
|
811
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
812
|
+
) -> set[str]:
|
|
813
|
+
return {
|
|
814
|
+
each_node.id
|
|
815
|
+
for each_node in ast.walk(function_node)
|
|
816
|
+
if isinstance(each_node, ast.Name)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _docstring_inline_code_tokens(docstring_text: str) -> set[str]:
|
|
821
|
+
tokens: set[str] = set()
|
|
822
|
+
for each_match in INLINE_CODE_TOKEN_PATTERN.finditer(docstring_text):
|
|
823
|
+
token = each_match.group(1).strip().lstrip(".")
|
|
824
|
+
if token:
|
|
825
|
+
tokens.add(token)
|
|
826
|
+
return tokens
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def check_docstring_tuple_enumeration_match(content: str, file_path: str) -> list[str]:
|
|
830
|
+
"""Flag a docstring enumeration that drifts from a literal tuple it reads.
|
|
831
|
+
|
|
832
|
+
The drift this catches: a function reads a module-level tuple of literal
|
|
833
|
+
string members and its docstring enumerates inline-code tokens that name
|
|
834
|
+
some of those members, but the enumerated set and the tuple membership
|
|
835
|
+
differ. A token the docstring lists that the tuple lacks, or a tuple member
|
|
836
|
+
the docstring omits, misleads a reader who trusts the prose enumeration to
|
|
837
|
+
match the detection set — the deterministic slice of Category O6
|
|
838
|
+
docstring-prose-vs-implementation drift. The check binds only when the
|
|
839
|
+
docstring's inline-code tokens overlap the tuple membership, so a docstring
|
|
840
|
+
that names unrelated attributes is left alone. This check covers hook
|
|
841
|
+
infrastructure, where the affected detection tuples live.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
content: The source text to inspect.
|
|
845
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
One issue per function whose docstring enumeration diverges from the
|
|
849
|
+
tuple it reads, capped at the module limit.
|
|
850
|
+
"""
|
|
851
|
+
if is_strict_test_file(file_path):
|
|
852
|
+
return []
|
|
853
|
+
try:
|
|
854
|
+
parsed_tree = ast.parse(content)
|
|
855
|
+
except SyntaxError:
|
|
856
|
+
return []
|
|
857
|
+
members_by_constant = _module_string_tuple_members(parsed_tree)
|
|
858
|
+
if not members_by_constant:
|
|
859
|
+
return []
|
|
860
|
+
issues: list[str] = []
|
|
861
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
862
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
863
|
+
continue
|
|
864
|
+
docstring_text = _function_docstring_text(each_node)
|
|
865
|
+
if not docstring_text:
|
|
866
|
+
continue
|
|
867
|
+
docstring_tokens = _docstring_inline_code_tokens(docstring_text)
|
|
868
|
+
if not docstring_tokens:
|
|
869
|
+
continue
|
|
870
|
+
referenced_names = _names_referenced_in_function(each_node)
|
|
871
|
+
for each_constant_name in referenced_names & set(members_by_constant):
|
|
872
|
+
tuple_members = members_by_constant[each_constant_name]
|
|
873
|
+
if not (docstring_tokens & tuple_members):
|
|
874
|
+
continue
|
|
875
|
+
if docstring_tokens == tuple_members:
|
|
876
|
+
continue
|
|
877
|
+
docstring_only = sorted(docstring_tokens - tuple_members)
|
|
878
|
+
tuple_only = sorted(tuple_members - docstring_tokens)
|
|
879
|
+
issues.append(
|
|
880
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring enumerates "
|
|
881
|
+
f"{sorted(docstring_tokens)} but {each_constant_name} holds "
|
|
882
|
+
f"{sorted(tuple_members)} — docstring-only: {docstring_only}, "
|
|
883
|
+
f"tuple-only: {tuple_only}; match the enumeration to the tuple "
|
|
884
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
885
|
+
)
|
|
886
|
+
if len(issues) >= MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES:
|
|
887
|
+
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
888
|
+
return issues[:MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES]
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _called_callee_name(statement: ast.stmt) -> str:
|
|
892
|
+
candidate_expression: ast.expr | None = None
|
|
893
|
+
if isinstance(statement, ast.Expr):
|
|
894
|
+
candidate_expression = statement.value
|
|
895
|
+
elif isinstance(statement, ast.Assign):
|
|
896
|
+
candidate_expression = statement.value
|
|
897
|
+
elif isinstance(statement, ast.AnnAssign):
|
|
898
|
+
candidate_expression = statement.value
|
|
899
|
+
if candidate_expression is None:
|
|
900
|
+
return ""
|
|
901
|
+
if isinstance(candidate_expression, ast.Await):
|
|
902
|
+
candidate_expression = candidate_expression.value
|
|
903
|
+
if not isinstance(candidate_expression, ast.Call):
|
|
904
|
+
return ""
|
|
905
|
+
return _call_callee_name(candidate_expression)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _called_callees_in_expression(expression: ast.expr) -> set[str]:
|
|
909
|
+
callees: set[str] = set()
|
|
910
|
+
for each_descendant in ast.walk(expression):
|
|
911
|
+
if not isinstance(each_descendant, ast.Call):
|
|
912
|
+
continue
|
|
913
|
+
callee_name = _call_callee_name(each_descendant)
|
|
914
|
+
if callee_name:
|
|
915
|
+
callees.add(callee_name)
|
|
916
|
+
return callees
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _linear_step_callees(
|
|
920
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
921
|
+
) -> set[str]:
|
|
922
|
+
callees: set[str] = set()
|
|
923
|
+
for each_statement in function_node.body:
|
|
924
|
+
callee_name = _called_callee_name(each_statement)
|
|
925
|
+
if callee_name:
|
|
926
|
+
callees.add(callee_name)
|
|
927
|
+
if isinstance(each_statement, ast.If):
|
|
928
|
+
callees |= _called_callees_in_expression(each_statement.test)
|
|
929
|
+
return callees
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _branch_guarded_dispatch_callees(
|
|
933
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
934
|
+
) -> set[str]:
|
|
935
|
+
callees: set[str] = set()
|
|
936
|
+
for each_statement in function_node.body:
|
|
937
|
+
if not isinstance(each_statement, ast.If):
|
|
938
|
+
continue
|
|
939
|
+
for each_branch_statement in each_statement.body + each_statement.orelse:
|
|
940
|
+
for each_descendant in ast.walk(each_branch_statement):
|
|
941
|
+
if not isinstance(each_descendant, ast.If):
|
|
942
|
+
continue
|
|
943
|
+
callees |= _called_callees_in_expression(each_descendant.test)
|
|
944
|
+
return callees
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _bare_callee_name(qualified_callee: str) -> str:
|
|
948
|
+
return qualified_callee.rsplit(".", 1)[-1]
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _docstring_names_all_callee_tokens(docstring_text: str, callee_name: str) -> bool:
|
|
952
|
+
bare_name = _bare_callee_name(callee_name)
|
|
953
|
+
lowered_docstring = docstring_text.lower()
|
|
954
|
+
if bare_name.lower() in lowered_docstring:
|
|
955
|
+
return True
|
|
956
|
+
callee_tokens = _name_tokens(bare_name)
|
|
957
|
+
if not callee_tokens:
|
|
958
|
+
return False
|
|
959
|
+
return all(each_token.lower() in lowered_docstring for each_token in callee_tokens)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def check_docstring_step_enumeration_dispatch_coverage(
|
|
963
|
+
content: str, file_path: str
|
|
964
|
+
) -> list[str]:
|
|
965
|
+
"""Flag a step-enumeration docstring that omits a conditional dispatch call.
|
|
966
|
+
|
|
967
|
+
The drift this catches: a function whose docstring enumerates a linear
|
|
968
|
+
sequence of steps (``Navigates ..., searches ..., clicks ..., uploads ...``)
|
|
969
|
+
matching the body's linear-step calls, while the body also routes to a
|
|
970
|
+
corrective workflow step inside an ``if``/``elif`` branch — a cancel-and-reinitiate
|
|
971
|
+
or replace-target-row step — whose name the prose never spells out. A reader
|
|
972
|
+
who trusts the step list to be complete misses that the function can take that
|
|
973
|
+
conditional path. This is the deterministic slice of Category O4 (step-ordering
|
|
974
|
+
narrative): a body that guards a branch-only workflow step the enumeration omits.
|
|
975
|
+
|
|
976
|
+
A linear-step call is one made as a top-level statement or inside the ``If.test``
|
|
977
|
+
guard of a top-level ``if`` (``if not await self.navigate(): return``). A
|
|
978
|
+
dispatch step is a call inside a guard (``If.test``) nested within an
|
|
979
|
+
``if``/``elif`` branch (``if not await cancel_and_reinitiate_update(...): return``)
|
|
980
|
+
that is never also a linear step — the same control-flow-gating shape as a
|
|
981
|
+
linear step, so plain (unguarded) logging, screenshot, or method-on-local
|
|
982
|
+
calls inside a branch body are not dispatch steps. The check binds only when
|
|
983
|
+
the docstring already names two or more linear-step callees by their
|
|
984
|
+
underscore tokens, proving the prose is a step enumeration describing this
|
|
985
|
+
body. A dispatch-step callee with two or more underscore tokens, none of
|
|
986
|
+
whose tokens appear in the prose, is flagged.
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
content: The source text to inspect.
|
|
990
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
991
|
+
|
|
992
|
+
Returns:
|
|
993
|
+
One issue per conditional dispatch call the step enumeration omits, capped
|
|
994
|
+
at the module limit.
|
|
995
|
+
"""
|
|
996
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
997
|
+
return []
|
|
998
|
+
try:
|
|
999
|
+
parsed_tree = ast.parse(content)
|
|
1000
|
+
except SyntaxError:
|
|
1001
|
+
return []
|
|
1002
|
+
issues: list[str] = []
|
|
1003
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1004
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1005
|
+
continue
|
|
1006
|
+
if _function_has_exempt_decorator(each_node):
|
|
1007
|
+
continue
|
|
1008
|
+
docstring_text = _function_docstring_text(each_node)
|
|
1009
|
+
if not docstring_text:
|
|
1010
|
+
continue
|
|
1011
|
+
linear_step_callees = _linear_step_callees(each_node)
|
|
1012
|
+
named_linear_steps = [
|
|
1013
|
+
each_callee
|
|
1014
|
+
for each_callee in linear_step_callees
|
|
1015
|
+
if _docstring_names_all_callee_tokens(docstring_text, each_callee)
|
|
1016
|
+
]
|
|
1017
|
+
if len(named_linear_steps) < MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK:
|
|
1018
|
+
continue
|
|
1019
|
+
branch_only_callees = (
|
|
1020
|
+
_branch_guarded_dispatch_callees(each_node) - linear_step_callees
|
|
1021
|
+
)
|
|
1022
|
+
for each_callee in sorted(branch_only_callees):
|
|
1023
|
+
if len(_name_tokens(_bare_callee_name(each_callee))) < MINIMUM_TOKENS_FOR_DISPATCH_CALLEE:
|
|
1024
|
+
continue
|
|
1025
|
+
if _docstring_names_all_callee_tokens(docstring_text, each_callee):
|
|
1026
|
+
continue
|
|
1027
|
+
issues.append(
|
|
1028
|
+
f"Line {each_node.lineno}: {each_node.name}() docstring enumerates linear "
|
|
1029
|
+
f"steps but omits the conditional dispatch step {each_callee}() the body "
|
|
1030
|
+
"guards inside a branch — add the corrective-path step to the enumeration "
|
|
1031
|
+
"(Category O4 step-ordering narrative drift)"
|
|
1032
|
+
)
|
|
1033
|
+
if len(issues) >= MAX_DOCSTRING_STEP_DISPATCH_ISSUES:
|
|
1034
|
+
return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
|
|
1035
|
+
return issues[:MAX_DOCSTRING_STEP_DISPATCH_ISSUES]
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _imported_binding_names(import_node: ast.Import | ast.ImportFrom) -> set[str]:
|
|
1039
|
+
bound_names: set[str] = set()
|
|
1040
|
+
for each_alias in import_node.names:
|
|
1041
|
+
bound_names.add(each_alias.asname or each_alias.name.split(".", 1)[0])
|
|
1042
|
+
return bound_names
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _module_defined_and_imported_names(parsed_tree: ast.Module) -> set[str]:
|
|
1046
|
+
defined_names: set[str] = set()
|
|
1047
|
+
for each_node in ast.walk(parsed_tree):
|
|
1048
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
1049
|
+
defined_names |= _imported_binding_names(each_node)
|
|
1050
|
+
elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
1051
|
+
defined_names.add(each_node.name)
|
|
1052
|
+
elif isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Store):
|
|
1053
|
+
defined_names.add(each_node.id)
|
|
1054
|
+
return defined_names
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _module_attribute_access_names(parsed_tree: ast.Module) -> set[str]:
|
|
1058
|
+
attribute_names: set[str] = set()
|
|
1059
|
+
for each_node in ast.walk(parsed_tree):
|
|
1060
|
+
if isinstance(each_node, ast.Attribute):
|
|
1061
|
+
attribute_names.add(each_node.attr)
|
|
1062
|
+
return attribute_names
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _docstring_constant_node_ids(parsed_tree: ast.Module) -> set[int]:
|
|
1066
|
+
docstring_node_ids: set[int] = set()
|
|
1067
|
+
for each_node in ast.walk(parsed_tree):
|
|
1068
|
+
if not isinstance(
|
|
1069
|
+
each_node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1070
|
+
):
|
|
1071
|
+
continue
|
|
1072
|
+
body_statements = each_node.body
|
|
1073
|
+
if not body_statements or not _statement_is_docstring(body_statements[0]):
|
|
1074
|
+
continue
|
|
1075
|
+
first_statement = body_statements[0]
|
|
1076
|
+
assert isinstance(first_statement, ast.Expr)
|
|
1077
|
+
docstring_node_ids.add(id(first_statement.value))
|
|
1078
|
+
return docstring_node_ids
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _module_string_literal_word_runs(parsed_tree: ast.Module) -> set[str]:
|
|
1082
|
+
docstring_node_ids = _docstring_constant_node_ids(parsed_tree)
|
|
1083
|
+
word_runs: set[str] = set()
|
|
1084
|
+
for each_node in ast.walk(parsed_tree):
|
|
1085
|
+
if not (isinstance(each_node, ast.Constant) and isinstance(each_node.value, str)):
|
|
1086
|
+
continue
|
|
1087
|
+
if id(each_node) in docstring_node_ids:
|
|
1088
|
+
continue
|
|
1089
|
+
for each_run in re.findall(r"[A-Za-z0-9_]+", each_node.value):
|
|
1090
|
+
if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(each_run):
|
|
1091
|
+
word_runs.add(each_run)
|
|
1092
|
+
return word_runs
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _name_word_prefix_families(all_supporting_names: set[str]) -> set[str]:
|
|
1096
|
+
prefix_families: set[str] = set()
|
|
1097
|
+
for each_name in all_supporting_names:
|
|
1098
|
+
leading_word = each_name.split("_", 1)[0]
|
|
1099
|
+
prefix_families.add(leading_word)
|
|
1100
|
+
return prefix_families
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _token_is_word_run_of_any_name(token: str, all_supporting_names: set[str]) -> bool:
|
|
1104
|
+
return any(f"_{token}_" in f"_{each_name}_" for each_name in all_supporting_names)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _docstring_words(docstring_text: str) -> list[str]:
|
|
1108
|
+
return [
|
|
1109
|
+
each_word.strip(".,:;()[]{}'\"`")
|
|
1110
|
+
for each_word in docstring_text.replace("`", " ").split()
|
|
1111
|
+
]
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _docstring_frames_token_as_non_constant_reference(
|
|
1115
|
+
token: str, docstring_text: str
|
|
1116
|
+
) -> bool:
|
|
1117
|
+
if any(
|
|
1118
|
+
f"{token}{each_suffix}" in docstring_text
|
|
1119
|
+
for each_suffix in ALL_DOCSTRING_FILE_REFERENCE_SUFFIXES
|
|
1120
|
+
):
|
|
1121
|
+
return True
|
|
1122
|
+
words = _docstring_words(docstring_text)
|
|
1123
|
+
for each_index, each_word in enumerate(words):
|
|
1124
|
+
if each_word != token:
|
|
1125
|
+
continue
|
|
1126
|
+
neighbors = words[max(each_index - DOCSTRING_REFERENCE_MARKER_WINDOW, 0) : each_index + DOCSTRING_REFERENCE_MARKER_WINDOW + 1]
|
|
1127
|
+
if any(
|
|
1128
|
+
each_neighbor.lower() in ALL_DOCSTRING_NON_CONSTANT_REFERENCE_MARKERS
|
|
1129
|
+
for each_neighbor in neighbors
|
|
1130
|
+
):
|
|
1131
|
+
return True
|
|
1132
|
+
return False
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _docstring_constant_token_is_supported(
|
|
1136
|
+
token: str, parsed_tree: ast.Module, all_known_names: set[str], docstring_text: str
|
|
1137
|
+
) -> bool:
|
|
1138
|
+
supporting_predicates = (
|
|
1139
|
+
lambda: token in all_known_names,
|
|
1140
|
+
lambda: token in ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS,
|
|
1141
|
+
lambda: token in _module_attribute_access_names(parsed_tree),
|
|
1142
|
+
lambda: token in _module_string_literal_word_runs(parsed_tree),
|
|
1143
|
+
lambda: _token_is_word_run_of_any_name(token, all_known_names),
|
|
1144
|
+
lambda: _docstring_frames_token_as_non_constant_reference(token, docstring_text),
|
|
1145
|
+
lambda: token.split("_", 1)[0] in _name_word_prefix_families(all_known_names),
|
|
1146
|
+
)
|
|
1147
|
+
return any(each_predicate() for each_predicate in supporting_predicates)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _docstring_constant_tokens(docstring_text: str) -> set[str]:
|
|
1151
|
+
candidate_tokens: set[str] = set()
|
|
1152
|
+
for each_word in docstring_text.replace("`", " ").split():
|
|
1153
|
+
stripped_word = each_word.strip(".,:;()[]{}'\"")
|
|
1154
|
+
if stripped_word.startswith("__") and stripped_word.endswith("__"):
|
|
1155
|
+
continue
|
|
1156
|
+
if ALL_CAPS_WITH_UNDERSCORE_PATTERN.match(stripped_word):
|
|
1157
|
+
candidate_tokens.add(stripped_word)
|
|
1158
|
+
return candidate_tokens
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _documentable_nodes_with_docstrings(
|
|
1162
|
+
parsed_tree: ast.Module,
|
|
1163
|
+
) -> list[tuple[int, str]]:
|
|
1164
|
+
documentable: list[tuple[int, str]] = []
|
|
1165
|
+
module_docstring = ast.get_docstring(parsed_tree)
|
|
1166
|
+
if module_docstring:
|
|
1167
|
+
documentable.append((1, module_docstring))
|
|
1168
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
1169
|
+
if not isinstance(
|
|
1170
|
+
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
1171
|
+
):
|
|
1172
|
+
continue
|
|
1173
|
+
node_docstring = ast.get_docstring(each_node)
|
|
1174
|
+
if node_docstring:
|
|
1175
|
+
documentable.append((each_node.lineno, node_docstring))
|
|
1176
|
+
return documentable
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def check_docstring_names_undefined_constant(content: str, file_path: str) -> list[str]:
|
|
1180
|
+
"""Flag a docstring naming an UPPER_SNAKE constant nothing in the module backs.
|
|
1181
|
+
|
|
1182
|
+
The drift this catches: a docstring names an all-caps, underscore-joined
|
|
1183
|
+
token as a contract identifier (``NATIVE_EVALUATE_FUNCTION_NAME``) while the
|
|
1184
|
+
enclosing module carries no supporting reference for it. A reader who trusts
|
|
1185
|
+
the docstring to name a real symbol finds nothing — the deterministic slice
|
|
1186
|
+
of Category O6 docstring-prose-vs-implementation drift where the named token
|
|
1187
|
+
is structurally a constant and unresolvable against the module.
|
|
1188
|
+
|
|
1189
|
+
A token counts as supported, and is left alone, when any of these holds: it
|
|
1190
|
+
is defined at module scope or imported; it is a naming-convention descriptor
|
|
1191
|
+
(``UPPER_SNAKE_CASE`` and its siblings, describing a style, not a symbol); it
|
|
1192
|
+
is the attribute of an attribute access in the body (``os.O_NOFOLLOW``,
|
|
1193
|
+
``config.timing.MAX_DELAY``, resolving stdlib and dotted-import constants);
|
|
1194
|
+
it is an all-caps word run inside a string literal (an env-var key read via
|
|
1195
|
+
``os.environ[...]`` or ``os.getenv(...)``, an API enum string value, a doc
|
|
1196
|
+
stem in ``CODE_RULES.md``); it is a contiguous word run of a defined or
|
|
1197
|
+
imported name (``GH_TOKEN`` within ``ALL_GH_TOKEN_ENV_VAR_NAMES``); it shares
|
|
1198
|
+
a leading word component with a defined or imported name, marking the same
|
|
1199
|
+
enum family (``MODE_CLASSIFY`` beside an imported ``MODE_STRICT``); or the
|
|
1200
|
+
docstring prose frames it as a non-constant reference — followed by a file
|
|
1201
|
+
suffix (``CODE_RULES.md``) or sitting within two words of a marker such as
|
|
1202
|
+
``rule``, ``doc``, ``file``, ``env``, ``variable``, ``set``, ``read``,
|
|
1203
|
+
``per``, ``follows``, or ``see`` (``per CODE_RULES``, ``LLM_SETTINGS_ROOT is
|
|
1204
|
+
set to``). Single-segment all-caps acronyms (``HTTP``, ``JSON``) and dunder
|
|
1205
|
+
names (``__all__``) are not constants and are left alone.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
content: The source text to inspect.
|
|
1209
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1210
|
+
|
|
1211
|
+
Returns:
|
|
1212
|
+
One issue per docstring token that no module reference backs, capped at
|
|
1213
|
+
the module limit.
|
|
1214
|
+
"""
|
|
1215
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1216
|
+
return []
|
|
1217
|
+
try:
|
|
1218
|
+
parsed_tree = ast.parse(content)
|
|
1219
|
+
except SyntaxError:
|
|
1220
|
+
return []
|
|
1221
|
+
known_names = _module_defined_and_imported_names(parsed_tree)
|
|
1222
|
+
issues: list[str] = []
|
|
1223
|
+
for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
|
|
1224
|
+
for each_token in sorted(_docstring_constant_tokens(each_docstring)):
|
|
1225
|
+
if _docstring_constant_token_is_supported(
|
|
1226
|
+
each_token, parsed_tree, known_names, each_docstring
|
|
1227
|
+
):
|
|
1228
|
+
continue
|
|
1229
|
+
issues.append(
|
|
1230
|
+
f"Line {each_line_number}: docstring names '{each_token}' which the "
|
|
1231
|
+
"module neither defines at module scope nor imports — name the real "
|
|
1232
|
+
"symbol or drop the reference (Category O6 docstring-vs-implementation "
|
|
1233
|
+
"drift)"
|
|
1234
|
+
)
|
|
1235
|
+
if len(issues) >= MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES:
|
|
1236
|
+
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|
|
1237
|
+
return issues[:MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES]
|
|
@@ -69,7 +69,12 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
69
69
|
check_docstring_args_match_signature,
|
|
70
70
|
check_docstring_fallback_branch_coverage,
|
|
71
71
|
check_docstring_format,
|
|
72
|
+
check_docstring_names_undefined_constant,
|
|
72
73
|
check_docstring_no_consumer_claim,
|
|
74
|
+
check_docstring_no_inline_literal_claim,
|
|
75
|
+
check_docstring_step_enumeration_dispatch_coverage,
|
|
76
|
+
check_docstring_tuple_enumeration_match,
|
|
77
|
+
check_module_docstring_names_public_checks,
|
|
73
78
|
)
|
|
74
79
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
75
80
|
advise_cross_skill_duplicate_helper,
|
|
@@ -255,9 +260,26 @@ def validate_content(
|
|
|
255
260
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
256
261
|
all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
|
|
257
262
|
all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
|
|
263
|
+
all_issues.extend(
|
|
264
|
+
check_docstring_no_inline_literal_claim(effective_content, file_path)
|
|
265
|
+
)
|
|
258
266
|
all_issues.extend(
|
|
259
267
|
check_class_docstring_names_public_methods(effective_content, file_path)
|
|
260
268
|
)
|
|
269
|
+
all_issues.extend(
|
|
270
|
+
check_module_docstring_names_public_checks(effective_content, file_path)
|
|
271
|
+
)
|
|
272
|
+
all_issues.extend(
|
|
273
|
+
check_docstring_tuple_enumeration_match(effective_content, file_path)
|
|
274
|
+
)
|
|
275
|
+
all_issues.extend(
|
|
276
|
+
check_docstring_step_enumeration_dispatch_coverage(
|
|
277
|
+
effective_content, file_path
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
all_issues.extend(
|
|
281
|
+
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
282
|
+
)
|
|
261
283
|
all_issues.extend(
|
|
262
284
|
check_boolean_naming(
|
|
263
285
|
effective_content,
|