claude-dev-env 1.71.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.
Files changed (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -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,27 +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,
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,
27
35
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT,
36
+ DOCSTRING_REFERENCE_MARKER_WINDOW,
28
37
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT,
29
38
  MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES,
30
39
  MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES,
31
40
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES,
32
41
  MAX_DOCSTRING_FORMAT_ISSUES,
42
+ MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES,
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,
33
50
  MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
51
+ MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
52
+ MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
34
53
  )
35
54
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
55
+ ALL_CAPS_WITH_UNDERSCORE_PATTERN,
36
56
  ALL_DOCSTRING_ARGS_SECTION_HEADERS,
37
57
  ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
38
58
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
39
59
  DOCSTRING_ARG_ENTRY_PATTERN,
60
+ IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN,
61
+ INLINE_CODE_TOKEN_PATTERN,
40
62
  )
41
63
 
42
64
 
@@ -559,3 +581,657 @@ def check_class_docstring_names_public_methods(
559
581
  if len(issues) >= MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES:
560
582
  break
561
583
  return issues[:MAX_CLASS_DOCSTRING_PUBLIC_METHOD_ISSUES]
584
+
585
+
586
+ def _docstring_claims_no_consumer(docstring_text: str) -> str:
587
+ lowered_docstring = docstring_text.lower()
588
+ for each_phrase in ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES:
589
+ if each_phrase in lowered_docstring:
590
+ return each_phrase
591
+ return ""
592
+
593
+
594
+ def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]:
595
+ """Flag a docstring that asserts no consumer reads its produced artifact yet.
596
+
597
+ A producer docstring claiming "no consumer reads it yet" (or
598
+ "producer-only artifact") is a transitional statement that drifts the moment
599
+ a consumer lands. Once a submission run, gate, or any reader loads the
600
+ artifact, the claim contradicts both the live behavior and any companion
601
+ SKILL.md that documents the consumer — the Category O8 docstring /
602
+ companion-doc producer-consumer drift. The claim is also a no-historical /
603
+ no-transitional-language violation in its own right: a docstring describes
604
+ the contract that exists, not a not-yet-wired future. Rephrase to state what
605
+ reads the artifact, or drop the no-consumer sentence entirely.
606
+
607
+ Args:
608
+ content: The source text to inspect.
609
+ file_path: The path the source will be written to, used for exemptions.
610
+
611
+ Returns:
612
+ One issue per function whose docstring claims no consumer reads its
613
+ output, capped at the module limit.
614
+ """
615
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
616
+ return []
617
+ try:
618
+ parsed_tree = ast.parse(content)
619
+ except SyntaxError:
620
+ return []
621
+ issues: list[str] = []
622
+ for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
623
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
624
+ continue
625
+ if _function_has_exempt_decorator(each_node):
626
+ continue
627
+ docstring_text = _function_docstring_text(each_node)
628
+ if not docstring_text:
629
+ continue
630
+ matched_phrase = _docstring_claims_no_consumer(docstring_text)
631
+ if not matched_phrase:
632
+ continue
633
+ issues.append(
634
+ f"Line {each_node.lineno}: {each_node.name}() docstring claims "
635
+ f"'{matched_phrase}' — a no-consumer-yet claim drifts the moment a reader "
636
+ "lands and contradicts any companion SKILL.md; state what reads the artifact "
637
+ "or drop the sentence (Category O8 docstring / companion-doc drift)"
638
+ )
639
+ if len(issues) >= MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES:
640
+ break
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]